Эх сурвалжийг харах

Merge branch 'codex/reader-ui-refactor'

# Conflicts:
#	app/src/main/java/io/legado/app/model/ReadBook.kt
#	app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
#	app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
HapeLee 2 сар өмнө
parent
commit
c33f4d230c
100 өөрчлөгдсөн 8602 нэмэгдсэн , 4779 устгасан
  1. 0 6
      app/src/main/java/io/legado/app/App.kt
  2. 9 2
      app/src/main/java/io/legado/app/constant/PageAnim.kt
  3. 2 0
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 26 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudStartPosition.kt
  5. 1773 0
      app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt
  6. 22 0
      app/src/main/java/io/legado/app/feature/reader/core/accessibility/ReaderAccessibilityPolicy.kt
  7. 79 0
      app/src/main/java/io/legado/app/feature/reader/core/gesture/PullBookmarkGesture.kt
  8. 11 0
      app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicy.kt
  9. 12 0
      app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderMainAxisPolicy.kt
  10. 62 0
      app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderPageViewportLayout.kt
  11. 79 0
      app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderTapAction.kt
  12. 132 0
      app/src/main/java/io/legado/app/feature/reader/core/layout/ChineseLineBreaker.kt
  13. 414 0
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurer.kt
  14. 24 0
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderColumnMode.kt
  15. 42 0
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderLayoutMath.kt
  16. 675 0
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderPaginator.kt
  17. 51 0
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderTextShaper.kt
  18. 4 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderBackgroundAlpha.kt
  19. 39 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderBookmarkBadge.kt
  20. 30 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderEmphasisUnderlineRun.kt
  21. 10 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderImageCachePolicy.kt
  22. 34 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderImageDrawLayout.kt
  23. 46 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderNineSliceLayout.kt
  24. 215 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt
  25. 64 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPageColorTransform.kt
  26. 49 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderTextBackgroundRun.kt
  27. 5 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderTipColor.kt
  28. 21 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderTipRowLayout.kt
  29. 82 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderTipValue.kt
  30. 30 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderUnderlineRun.kt
  31. 22 0
      app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderChapterPaginationSnapshot.kt
  32. 127 0
      app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt
  33. 95 0
      app/src/main/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapter.kt
  34. 44 0
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSearchMatcher.kt
  35. 190 0
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelection.kt
  36. 31 0
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelectionBounds.kt
  37. 28 0
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelectionCoordinates.kt
  38. 20 0
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelectionLifecyclePolicy.kt
  39. 25 0
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelectionMenuAnchor.kt
  40. 162 0
      app/src/main/java/io/legado/app/feature/reader/core/source/ReaderChapterSource.kt
  41. 41 0
      app/src/main/java/io/legado/app/feature/reader/core/source/ReaderTitleSegmentation.kt
  42. 41 0
      app/src/main/java/io/legado/app/feature/reader/core/style/ReaderCharacterStyle.kt
  43. 182 0
      app/src/main/java/io/legado/app/feature/reader/core/transition/PageCurlGeometry.kt
  44. 27 0
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderAutoPagePolicy.kt
  45. 213 0
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderPageTransition.kt
  46. 87 0
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderScrollState.kt
  47. 10 0
      app/src/main/java/io/legado/app/feature/reader/core/transition/ReaderViewportLayerPolicy.kt
  48. 230 0
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt
  49. 40 0
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderImageOptionsResolver.kt
  50. 262 0
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderPageDecorationFactory.kt
  51. 118 0
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderPaginationStyleFactory.kt
  52. 163 0
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderStyleRangeMapper.kt
  53. 175 0
      app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderHtmlSourceResolver.kt
  54. 84 0
      app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderTextShaper.kt
  55. 40 0
      app/src/main/java/io/legado/app/feature/reader/platform/ReaderAndroidPaginationStyle.kt
  56. 97 0
      app/src/main/java/io/legado/app/feature/reader/platform/ReaderBookmarkBadgeRenderer.kt
  57. 137 0
      app/src/main/java/io/legado/app/feature/reader/platform/ReaderPageDecorationDrawCache.kt
  58. 96 0
      app/src/main/java/io/legado/app/feature/reader/platform/ReaderTextBackgroundLoader.kt
  59. 0 19
      app/src/main/java/io/legado/app/help/readaloud/segment/TextChapterSpeechAdapter.kt
  60. 1 1
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  61. 7 2
      app/src/main/java/io/legado/app/model/ReadAloud.kt
  62. 231 278
      app/src/main/java/io/legado/app/model/ReadBook.kt
  63. 13 3
      app/src/main/java/io/legado/app/model/ReadSessionState.kt
  64. 29 0
      app/src/main/java/io/legado/app/model/reader/ReaderChapterInput.kt
  65. 91 79
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  66. 41 43
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  67. 6 4
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  68. 2 2
      app/src/main/java/io/legado/app/ui/book/read/BookmarkBadgeDelegate.kt
  69. 2 2
      app/src/main/java/io/legado/app/ui/book/read/MarkingDelegate.kt
  70. 7 3
      app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt
  71. 2 6
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  72. 9 18
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  73. 765 261
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  74. 14 8
      app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt
  75. 285 178
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  76. 64 14
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  77. 116 137
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  78. 18 19
      app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt
  79. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateBus.kt
  80. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt
  81. 7 22
      app/src/main/java/io/legado/app/ui/book/read/ReadContentEditDelegate.kt
  82. 0 3
      app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleDelegate.kt
  83. 0 144
      app/src/main/java/io/legado/app/ui/book/read/ReadSearchResultLocator.kt
  84. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadStyleDelegate.kt
  85. 13 2
      app/src/main/java/io/legado/app/ui/book/read/ReaderFirstFrame.kt
  86. 45 0
      app/src/main/java/io/legado/app/ui/book/read/ReaderRenderUiState.kt
  87. 1 2
      app/src/main/java/io/legado/app/ui/book/read/ReaderViewport.kt
  88. 0 161
      app/src/main/java/io/legado/app/ui/book/read/config/BgImageSpan.kt
  89. 0 168
      app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt
  90. 0 809
      app/src/main/java/io/legado/app/ui/book/read/page/ContentTextView.kt
  91. 0 19
      app/src/main/java/io/legado/app/ui/book/read/page/CustomTipPageRemaining.kt
  92. 0 888
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  93. 0 1103
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  94. 0 91
      app/src/main/java/io/legado/app/ui/book/read/page/ReaderBookmarkBadge.kt
  95. 0 46
      app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt
  96. 0 47
      app/src/main/java/io/legado/app/ui/book/read/page/ReaderPageSource.kt
  97. 0 19
      app/src/main/java/io/legado/app/ui/book/read/page/ResourceLoadFailureCache.kt
  98. 0 23
      app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt
  99. 0 26
      app/src/main/java/io/legado/app/ui/book/read/page/api/PageFactory.kt
  100. 0 117
      app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt

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

@@ -63,7 +63,6 @@ import io.legado.app.help.source.SourceHelp
 import io.legado.app.help.storage.Backup
 import io.legado.app.lib.theme.primaryColor
 import io.legado.app.model.BookCover
-import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.utils.ChineseUtils
 import io.legado.app.utils.FirebaseManager
 import io.legado.app.utils.LogUtils
@@ -237,11 +236,6 @@ class App : Application(), SingletonImageLoader.Factory {
         }
     }
 
-    override fun onTrimMemory(level: Int) {
-        super.onTrimMemory(level)
-        TextLine.trimCaches(level)
-    }
-
     /**
      * 尝试在安装了GMS的设备上(GMS或者MicroG)使用GMS内置的Conscrypt
      * 作为首选JCE提供程序,而使Okhttp在低版本Android上

+ 9 - 2
app/src/main/java/io/legado/app/constant/PageAnim.kt

@@ -18,7 +18,14 @@ object PageAnim {
 
     @Target(AnnotationTarget.VALUE_PARAMETER)
     @Retention(AnnotationRetention.SOURCE)
-    @IntDef(coverPageAnim, slidePageAnim, simulationPageAnim, scrollPageAnim, noAnim)
+    @IntDef(
+        coverPageAnim,
+        slidePageAnim,
+        simulationPageAnim,
+        scrollPageAnim,
+        fadePageAnim,
+        noAnim,
+    )
     annotation class Anim
 
-}
+}

+ 2 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -252,6 +252,7 @@ import io.legado.app.ui.book.knowledge.BookKnowledgeListViewModel
 import io.legado.app.ui.book.manage.BookshelfManageScreenViewModel
 import io.legado.app.ui.book.manga.MangaReaderViewModel
 import io.legado.app.ui.book.read.ReadBookViewModel
+import io.legado.app.ui.book.read.ReaderSessionViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordOverviewViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
 import io.legado.app.ui.book.readaloud.cache.TtsCacheViewModel
@@ -710,6 +711,7 @@ val appModule = module {
         )
     }
     viewModelOf(::MangaReaderViewModel)
+    viewModelOf(::ReaderSessionViewModel)
     viewModel {
         ReadBookViewModel(
             application = get(),

+ 26 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudStartPosition.kt

@@ -0,0 +1,26 @@
+package io.legado.app.domain.model.readaloud
+
+data class ReadAloudStartPosition(
+    val pageIndex: Int,
+    val offsetInPage: Int,
+)
+
+/**
+ * Resolves the new Canvas-facing absolute chapter position at the legacy service boundary.
+ * Page-relative callers remain supported until all external entry points migrate.
+ */
+fun resolveReadAloudStartPosition(
+    requestedPageIndex: Int,
+    requestedOffsetInPage: Int,
+    requestedChapterPosition: Int?,
+    pageIndexAt: (Int) -> Int,
+    pageStart: (Int) -> Int,
+): ReadAloudStartPosition {
+    val chapterPosition = requestedChapterPosition
+        ?: return ReadAloudStartPosition(requestedPageIndex, requestedOffsetInPage.coerceAtLeast(0))
+    val pageIndex = pageIndexAt(chapterPosition)
+    return ReadAloudStartPosition(
+        pageIndex = pageIndex,
+        offsetInPage = (chapterPosition - pageStart(pageIndex)).coerceAtLeast(0),
+    )
+}

+ 1773 - 0
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -0,0 +1,1773 @@
+package io.legado.app.feature.reader
+
+import android.graphics.Paint
+import io.legado.app.feature.reader.platform.ReaderBookmarkBadgeRenderer
+import android.graphics.Bitmap
+import android.graphics.BitmapShader
+import android.graphics.Shader
+import android.os.SystemClock
+import android.graphics.drawable.Drawable
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.snap
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.rememberSplineBasedDecay
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.nativeCanvas
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.ClipOp
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.clipPath
+import androidx.compose.ui.graphics.drawscope.clipRect
+import androidx.compose.ui.graphics.drawscope.withTransform
+import androidx.compose.ui.graphics.layer.drawLayer
+import androidx.compose.ui.graphics.rememberGraphicsLayer
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.semantics.CustomAccessibilityAction
+import androidx.compose.ui.semantics.ScrollAxisRange
+import androidx.compose.ui.semantics.customActions
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.scrollBy
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.semantics.text
+import androidx.compose.ui.semantics.verticalScrollAxisRange
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.input.pointer.*
+import androidx.compose.ui.input.pointer.util.VelocityTracker
+import io.legado.app.feature.reader.core.gesture.PullBookmarkDefaults
+import io.legado.app.feature.reader.core.gesture.PullBookmarkGesture
+import io.legado.app.feature.reader.core.gesture.ReaderMainAxisPolicy
+import io.legado.app.feature.reader.core.gesture.ReaderTapAction
+import io.legado.app.feature.reader.core.gesture.ReaderTapActionGrid
+import io.legado.app.feature.reader.core.gesture.ReaderGestureSettingsPolicy
+import io.legado.app.feature.reader.core.accessibility.ReaderAccessibilityPolicy
+import io.legado.app.feature.reader.core.model.*
+import io.legado.app.feature.reader.core.transition.*
+import io.legado.app.feature.reader.core.selection.ReaderSelection
+import io.legado.app.feature.reader.core.selection.ReaderSelectionEndpoint
+import io.legado.app.feature.reader.core.selection.ReaderPageChangeOrigin
+import io.legado.app.feature.reader.core.selection.ReaderSelectionLifecyclePolicy
+import io.legado.app.feature.reader.core.selection.ReaderSelectionMenuAnchor
+import io.legado.app.feature.reader.core.selection.ReaderSelectionPolicy
+import io.legado.app.feature.reader.core.selection.mergeSelectionBounds
+import io.legado.app.feature.reader.core.gesture.ReaderPageViewportLayout
+import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
+import io.legado.app.feature.reader.platform.ReaderPageDecorationDrawCache
+import io.legado.app.feature.reader.platform.ReaderTextBackgroundLoader
+import io.legado.app.feature.reader.core.model.emphasisUnderlineRuns
+import kotlin.math.abs
+import kotlin.math.hypot
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.PI
+import kotlin.math.roundToInt
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import io.legado.app.R
+
+/** Compose Canvas reader surface. Gesture arbitration and transforms are independent of ReadView. */
+// 把手圆挂在行底部下方、顶端圆周与竖线末端相切;圆区域在拖动命中时视作竖线的延伸。
+private val SelectionHandleRadius = 7.dp
+private val SelectionHandleStrokeWidth = 2.dp
+
+@Composable
+fun ReaderCanvasSurface(
+    pages: ReaderPageWindow,
+    transitionMode: ReaderTransitionMode,
+    backgroundColor: Color,
+    backgroundImage: Drawable?,
+    backgroundRevision: Long,
+    backgroundImageAlpha: Float,
+    selectionColor: Color,
+    textAccentColor: Color,
+    autoPageIndicatorColor: Color,
+    modifier: Modifier = Modifier,
+    onPreviousPage: () -> Unit,
+    onNextPage: () -> Unit,
+    onToggleMenu: () -> Unit,
+    onToggleBookmark: () -> Unit,
+    swipeToBookmarkEnabled: Boolean,
+    hasBookmarkOnCurrentPage: () -> Boolean,
+    cachedImage: (ReaderElement.Image) -> Bitmap?,
+    loadImage: suspend (ReaderElement.Image) -> Bitmap?,
+    autoPageActive: Boolean,
+    autoPagePaused: Boolean,
+    autoReadSpeedSeconds: Int,
+    isEInkMode: Boolean,
+    onAutoPageStop: () -> Unit,
+    onShowSelectionMenu: (ReaderSelection, String, ReaderSelectionMenuAnchor) -> Unit,
+    onDismissSelectionMenu: () -> Unit,
+    onElementClick: (ReaderElement) -> Boolean,
+    onElementLongPress: (ReaderElement, Float, Float) -> Boolean,
+    selectionEnabled: Boolean,
+    selectionHapticsEnabled: Boolean,
+    tapActionGrid: ReaderTapActionGrid,
+    onTapAction: (ReaderTapAction) -> Unit,
+    onReaderInteraction: () -> Unit,
+    configuredTouchSlopPx: Int,
+    noAnimationScrollPage: Boolean,
+    externalPageTurns: Flow<ReaderTurnDirection>,
+    externalSelectionCancels: Flow<Unit>,
+) {
+    val current = pages.current ?: return
+    val pageBackgroundImage = remember(backgroundImage, backgroundRevision) {
+        backgroundImage?.isolatedCopy()
+    }
+    val animationScope = rememberCoroutineScope()
+    val hapticFeedback = LocalHapticFeedback.current
+    val scrollDecay = rememberSplineBasedDecay<Float>()
+    var displayOffset by remember { mutableFloatStateOf(0f) }
+    var transition by remember { mutableStateOf(ReaderPageTransition()) }
+    var pageMotionJob by remember { mutableStateOf<Job?>(null) }
+    var pendingTurn by remember { mutableStateOf<ReaderTurnDirection?>(null) }
+    var pendingTurnOrigin by remember { mutableStateOf<ReaderPageId?>(null) }
+    val latestPreviousPage by rememberUpdatedState(onPreviousPage)
+    val latestNextPage by rememberUpdatedState(onNextPage)
+    val latestAutoPageStop by rememberUpdatedState(onAutoPageStop)
+    val latestAutoPagePaused by rememberUpdatedState(autoPagePaused)
+    val latestAutoPageActive by rememberUpdatedState(autoPageActive)
+    val latestShowSelectionMenu by rememberUpdatedState(onShowSelectionMenu)
+    val latestDismissSelectionMenu by rememberUpdatedState(onDismissSelectionMenu)
+    val latestSelectionHapticsEnabled by rememberUpdatedState(selectionHapticsEnabled)
+    val latestSelectionEnabled by rememberUpdatedState(selectionEnabled)
+    val latestTapActionGrid by rememberUpdatedState(tapActionGrid)
+    val latestTapAction by rememberUpdatedState(onTapAction)
+    val latestReaderInteraction by rememberUpdatedState(onReaderInteraction)
+    val latestNoAnimationScrollPage by rememberUpdatedState(noAnimationScrollPage)
+    var bookmarkOffset by remember { mutableFloatStateOf(0f) }
+    var bookmarkArmed by remember { mutableStateOf(false) }
+    var bookmarkWillRemove by remember { mutableStateOf(false) }
+    var bookmarkReturnJob by remember { mutableStateOf<Job?>(null) }
+    val latestSwipeToBookmarkEnabled by rememberUpdatedState(swipeToBookmarkEnabled)
+    val latestHasBookmark by rememberUpdatedState(hasBookmarkOnCurrentPage)
+    val latestToggleBookmark by rememberUpdatedState(onToggleBookmark)
+    DisposableEffect(transitionMode, current.widthPx, current.heightPx, current.layoutRevision) {
+        onDispose {
+            pageMotionJob?.cancel()
+            pendingTurn = null
+            pendingTurnOrigin = null
+            displayOffset = 0f
+            transition = ReaderPageTransition()
+            bookmarkReturnJob?.cancel()
+            bookmarkOffset = 0f
+            bookmarkArmed = false
+        }
+    }
+    var curlTouchY by remember { mutableFloatStateOf(1f) }
+    var curlTouchX by remember { mutableFloatStateOf(0f) }
+    var curlCornerY by remember { mutableFloatStateOf(0f) }
+    var scrollOffset by remember { mutableFloatStateOf(0f) }
+    var scrollCarryTarget by remember { mutableStateOf<ReaderPageId?>(null) }
+    var autoRevealPx by remember { mutableFloatStateOf(0f) }
+    var autoPageRemainingMillis by remember(current.id, autoReadSpeedSeconds) {
+        mutableLongStateOf(ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds))
+    }
+    var textSelection by remember { mutableStateOf<ReaderSelection?>(null) }
+    val latestSelectionPausesAutoPage by rememberUpdatedState(textSelection != null)
+    var selectionMenuVisible by remember { mutableStateOf(false) }
+    var selectionLayoutRevision by remember { mutableLongStateOf(current.layoutRevision) }
+    val latestPages by rememberUpdatedState(pages)
+    LaunchedEffect(
+        pages.previous?.id,
+        pages.previous?.revision,
+        pages.current.id,
+        pages.current.revision,
+        pages.next?.id,
+        pages.next?.revision,
+    ) {
+        // Paged modes do not compose the adjacent page until a gesture starts. Warm its images
+        // while the window is idle so the first animation frame never falls back to placeholders.
+        listOfNotNull(pages.next, pages.previous, pages.current)
+            .asSequence()
+            .flatMap { page -> page.elements.asSequence().filterIsInstance<ReaderElement.Image>() }
+            .distinctBy { element -> element.source to element.bounds }
+            .forEach { element -> launch { loadImage(element) } }
+    }
+    val transforms = transition.copy(offsetPx = displayOffset).transforms(transitionMode)
+    fun pageViewportLayout(window: ReaderPageWindow = latestPages): ReaderPageViewportLayout =
+        if (transitionMode == ReaderTransitionMode.SCROLL) {
+            ReaderPageViewportLayout.scroll(window, scrollOffset)
+        } else {
+            ReaderPageViewportLayout.paged(window)
+        }
+    fun dismissSelectionMenu() {
+        selectionMenuVisible = false
+        latestDismissSelectionMenu()
+    }
+    fun showSelectionMenu(selection: ReaderSelection, window: ReaderPageWindow): Boolean {
+        val bounds = pageViewportLayout(window).selectionBounds(selection).map { it.bounds }
+        val text = selection.selectedText(listOfNotNull(window.previous, window.current, window.next))
+        val anchor = ReaderSelectionMenuAnchor.from(bounds) ?: return false
+        if (text.isEmpty()) return false
+        selectionMenuVisible = true
+        latestShowSelectionMenu(selection, text, anchor)
+        return true
+    }
+    fun clearSelectionForPageChange(origin: ReaderPageChangeOrigin) {
+        if (textSelection != null && ReaderSelectionLifecyclePolicy.shouldClearForPageChange(origin)) {
+            textSelection = null
+            dismissSelectionMenu()
+        }
+    }
+    fun completePendingTurn() {
+        val direction = pendingTurn.takeIf { pendingTurnOrigin == latestPages.current?.id }
+        pendingTurn = null
+        pendingTurnOrigin = null
+        when (direction) {
+            ReaderTurnDirection.PREVIOUS -> latestPreviousPage()
+            ReaderTurnDirection.NEXT -> latestNextPage()
+            null -> Unit
+        }
+    }
+    fun settlePageTurn(decision: ReaderTransitionDecision) {
+        pageMotionJob?.cancel()
+        pendingTurn = transition.direction.takeIf { decision.commit }
+        pendingTurnOrigin = latestPages.current?.id
+        if (transitionMode == ReaderTransitionMode.NONE) {
+            completePendingTurn()
+            displayOffset = 0f
+            transition = ReaderPageTransition()
+            return
+        }
+        val targetCurlX = transition.direction?.let {
+            ReaderCurlTouchPolicy.settledX(it, decision.commit, transition.pageExtentPx)
+        } ?: curlTouchX
+        val durationMillis = if (transitionMode == ReaderTransitionMode.SIMULATION) {
+            ReaderCurlTouchPolicy.settleDurationMillis(
+                curlTouchX,
+                targetCurlX,
+                transition.pageExtentPx,
+            )
+        } else {
+            ReaderPageTransitionPolicy.settleDurationMillis(
+                transitionMode, displayOffset, decision.targetOffsetPx, transition.pageExtentPx,
+            )
+        }
+        if (durationMillis == 0) {
+            displayOffset = decision.targetOffsetPx
+            completePendingTurn()
+            displayOffset = 0f
+            transition = ReaderPageTransition()
+            return
+        }
+        val startOffset = displayOffset
+        val startCurlX = curlTouchX
+        val startCurlY = curlTouchY
+        val targetCurlY = ReaderCurlTouchPolicy.settledY(
+            curlCornerY,
+            latestPages.current?.heightPx?.toFloat() ?: 0f,
+        )
+        pageMotionJob = animationScope.launch {
+            Animatable(startOffset).animateTo(
+                decision.targetOffsetPx,
+                tween(durationMillis),
+            ) {
+                displayOffset = value
+                if (transitionMode == ReaderTransitionMode.SIMULATION) {
+                    val distance = decision.targetOffsetPx - startOffset
+                    val fraction = if (distance == 0f) 1f else ((value - startOffset) / distance).coerceIn(0f, 1f)
+                    curlTouchX = startCurlX + (targetCurlX - startCurlX) * fraction
+                    curlTouchY = startCurlY + (targetCurlY - startCurlY) * fraction
+                }
+            }
+            completePendingTurn()
+            displayOffset = 0f
+            transition = ReaderPageTransition()
+        }
+    }
+    fun tapPageTurn(direction: ReaderTurnDirection) {
+        val window = latestPages
+        if ((if (direction == ReaderTurnDirection.PREVIOUS) window.previous else window.next) == null) return
+        val width = window.current?.widthPx?.toFloat() ?: return
+        if (transitionMode == ReaderTransitionMode.SIMULATION) {
+            curlTouchX = ReaderCurlTouchPolicy.programmaticX(direction, width)
+            curlTouchY = ReaderCurlTouchPolicy.programmaticY(
+                direction, curlTouchY, window.current.heightPx.toFloat(),
+            )
+            curlCornerY = ReaderCurlTouchPolicy.cornerY(
+                direction, curlTouchY, window.current.heightPx.toFloat(),
+            )
+        }
+        val target = if (direction == ReaderTurnDirection.PREVIOUS) width else -width
+        transition = ReaderPageTransition(direction, 0f, width, dragging = true)
+        displayOffset = 0f
+        settlePageTurn(ReaderTransitionDecision(target, commit = true))
+    }
+    fun applyScrollResult(result: ReaderScrollResult, window: ReaderPageWindow) {
+        if (!ReaderScrollPolicy.canApplyDelta(scrollCarryTarget)) return
+        scrollOffset = result.offsetPx
+        when (result.crossing) {
+            ReaderScrollCrossing.PREVIOUS -> {
+                scrollCarryTarget = window.previous?.id
+                latestPreviousPage()
+            }
+            ReaderScrollCrossing.NEXT -> {
+                scrollCarryTarget = window.next?.id
+                latestNextPage()
+            }
+            null -> Unit
+        }
+    }
+    fun tapScrollPage(direction: ReaderTurnDirection) {
+        val page = latestPages.current ?: return
+        val distance = ReaderScrollPolicy.pageStep(page, scrollOffset, direction)
+        pageMotionJob?.cancel()
+        val steps = ReaderGestureSettingsPolicy.scrollPageAnimationSteps(latestNoAnimationScrollPage)
+        if (steps == 1) {
+            val window = latestPages
+            val currentPage = window.current ?: return
+            applyScrollResult(
+                ReaderScrollPolicy.apply(
+                    scrollOffset,
+                    distance,
+                    window.previous?.scrollExtentPx ?: 0f,
+                    currentPage.scrollExtentPx,
+                    currentPage.scrollViewportExtentPx(),
+                    window.previous != null,
+                    window.next != null,
+                ),
+                window,
+            )
+            return
+        }
+        pageMotionJob = animationScope.launch {
+            repeat(steps) {
+                val window = latestPages
+                val currentPage = window.current ?: return@launch
+                val result = ReaderScrollPolicy.apply(
+                    scrollOffset,
+                    distance / steps,
+                    window.previous?.scrollExtentPx ?: 0f,
+                    currentPage.scrollExtentPx,
+                    currentPage.scrollViewportExtentPx(),
+                    window.previous != null,
+                    window.next != null,
+                )
+                applyScrollResult(result, window)
+                if (result.hitBoundary) return@launch
+                delay(16)
+            }
+        }
+    }
+    fun dispatchTapAction(action: ReaderTapAction) {
+        when (action) {
+            ReaderTapAction.MENU -> onToggleMenu()
+            ReaderTapAction.NEXT_PAGE -> if (transitionMode == ReaderTransitionMode.SCROLL) {
+                tapScrollPage(ReaderTurnDirection.NEXT)
+            } else tapPageTurn(ReaderTurnDirection.NEXT)
+            ReaderTapAction.PREVIOUS_PAGE -> if (transitionMode == ReaderTransitionMode.SCROLL) {
+                tapScrollPage(ReaderTurnDirection.PREVIOUS)
+            } else tapPageTurn(ReaderTurnDirection.PREVIOUS)
+            else -> latestTapAction(action)
+        }
+    }
+    fun accessibilityPageTurn(direction: ReaderTurnDirection) {
+        if (!ReaderProgrammaticTurnPolicy.shouldAccept(pageMotionJob?.isActive == true)) return
+        clearSelectionForPageChange(ReaderPageChangeOrigin.PROGRAMMATIC)
+        dispatchTapAction(
+            if (direction == ReaderTurnDirection.PREVIOUS) {
+                ReaderTapAction.PREVIOUS_PAGE
+            } else {
+                ReaderTapAction.NEXT_PAGE
+            }
+        )
+    }
+    fun showComposeAccessibilityMenu() {
+        dispatchTapAction(ReaderTapAction.MENU)
+    }
+    LaunchedEffect(externalPageTurns) {
+        externalPageTurns.collect { direction ->
+            if (!ReaderProgrammaticTurnPolicy.shouldAccept(pageMotionJob?.isActive == true)) {
+                return@collect
+            }
+            clearSelectionForPageChange(ReaderPageChangeOrigin.PROGRAMMATIC)
+            displayOffset = 0f
+            transition = ReaderPageTransition()
+            dispatchTapAction(
+                if (direction == ReaderTurnDirection.PREVIOUS) {
+                    ReaderTapAction.PREVIOUS_PAGE
+                } else {
+                    ReaderTapAction.NEXT_PAGE
+                }
+            )
+        }
+    }
+    LaunchedEffect(externalSelectionCancels) {
+        externalSelectionCancels.collect {
+            textSelection = null
+            selectionMenuVisible = false
+        }
+    }
+    LaunchedEffect(current.id, current.layoutRevision, transitionMode) {
+        if (transitionMode == ReaderTransitionMode.SCROLL) {
+            scrollOffset = ReaderScrollPolicy.offsetAfterPageChange(
+                scrollOffset, scrollCarryTarget, current.id,
+            )
+        } else scrollOffset = 0f
+        scrollCarryTarget = null
+        if (pendingTurnOrigin != null && pendingTurnOrigin != current.id) {
+            pageMotionJob?.cancel()
+            pendingTurn = null
+            pendingTurnOrigin = null
+            displayOffset = 0f
+            transition = ReaderPageTransition()
+        }
+    }
+    LaunchedEffect(current.layoutRevision) {
+        val previousRevision = selectionLayoutRevision
+        selectionLayoutRevision = current.layoutRevision
+        val selection = textSelection
+        if (ReaderSelectionLifecyclePolicy.shouldReanchorMenuAfterLayoutChange(
+                hasSelection = selection != null,
+                menuVisible = selectionMenuVisible,
+                previousLayoutRevision = previousRevision,
+                currentLayoutRevision = current.layoutRevision,
+            ) && selection != null && !showSelectionMenu(selection, latestPages)
+        ) {
+            textSelection = null
+            dismissSelectionMenu()
+        }
+    }
+    LaunchedEffect(
+        autoPageActive,
+        autoPagePaused,
+        autoReadSpeedSeconds,
+        transitionMode,
+        isEInkMode,
+        current.id,
+        textSelection,
+    ) {
+        if (!autoPageActive) {
+            autoRevealPx = 0f
+            autoPageRemainingMillis = ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds)
+            return@LaunchedEffect
+        }
+        if (ReaderSelectionLifecyclePolicy.shouldPauseAutoPage(textSelection != null)) {
+            return@LaunchedEffect
+        }
+        if (autoPagePaused) return@LaunchedEffect
+        if (ReaderAutoPagePolicy.visualMode(isEInkMode) == ReaderAutoPageVisualMode.DISCRETE) {
+            autoRevealPx = 0f
+            val plannedMillis = autoPageRemainingMillis
+            val startedAt = SystemClock.uptimeMillis()
+            try {
+                delay(plannedMillis)
+                autoPageRemainingMillis = ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds)
+                if (latestPages.next == null) latestAutoPageStop() else latestNextPage()
+            } finally {
+                if (ReaderAutoPagePolicy.shouldPreserveRemainingTime(
+                        menuPaused = latestAutoPagePaused,
+                        selectionPaused = latestSelectionPausesAutoPage,
+                    )
+                ) {
+                    autoPageRemainingMillis = ReaderAutoPagePolicy.remainingAfterPause(
+                        plannedMillis,
+                        SystemClock.uptimeMillis() - startedAt,
+                    )
+                }
+            }
+            return@LaunchedEffect
+        }
+        var previousFrame = withFrameNanos { it }
+        while (true) {
+            val frame = withFrameNanos { it }
+            val elapsedMs = (frame - previousFrame) / 1_000_000f
+            previousFrame = frame
+            if (transitionMode == ReaderTransitionMode.SCROLL) {
+                val window = latestPages
+                val page = window.current ?: continue
+                val viewport = page.scrollViewportExtentPx()
+                val delta = viewport /
+                    ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds).toFloat() * elapsedMs
+                val result = ReaderScrollPolicy.apply(scrollOffset, -delta, window.previous?.scrollExtentPx ?: 0f, page.scrollExtentPx, page.scrollViewportExtentPx(), window.previous != null, window.next != null)
+                applyScrollResult(result, window)
+                if (result.hitBoundary) { latestAutoPageStop(); break }
+            } else {
+                val viewport = current.heightPx.toFloat().coerceAtLeast(1f)
+                val delta = viewport /
+                    ReaderAutoPagePolicy.pageDurationMillis(autoReadSpeedSeconds).toFloat() * elapsedMs
+                autoRevealPx += delta
+                if (autoRevealPx >= viewport) {
+                    if (latestPages.next == null) {
+                        latestAutoPageStop()
+                        break
+                    }
+                    latestNextPage()
+                    autoRevealPx = 0f
+                }
+            }
+        }
+    }
+    val bookmarkDescription = stringResource(io.legado.app.R.string.a11y_page_bookmarked)
+    val previousPageDescription = stringResource(io.legado.app.R.string.prev_page)
+    val nextPageDescription = stringResource(io.legado.app.R.string.next_page)
+    val menuDescription = stringResource(io.legado.app.R.string.menu)
+    val accessibilityPage = ReaderAccessibilityPolicy.snapshot(pages)
+    Box(modifier.clearAndSetSemantics {
+        accessibilityPage?.let { page ->
+            text = AnnotatedString(page.text)
+            if (page.isBookmarked) stateDescription = bookmarkDescription
+            verticalScrollAxisRange = ScrollAxisRange(
+                value = { if (page.canGoPrevious) 1f else 0f },
+                maxValue = {
+                    (if (page.canGoPrevious) 1f else 0f) +
+                        (if (page.canGoNext) 1f else 0f)
+                },
+            )
+            onClick(label = menuDescription) {
+                showComposeAccessibilityMenu()
+                true
+            }
+            scrollBy { x, y ->
+                val amount = if (abs(y) >= abs(x)) y else x
+                when {
+                    amount > 0f && page.canGoNext -> {
+                        accessibilityPageTurn(ReaderTurnDirection.NEXT)
+                        true
+                    }
+                    amount < 0f && page.canGoPrevious -> {
+                        accessibilityPageTurn(ReaderTurnDirection.PREVIOUS)
+                        true
+                    }
+                    else -> false
+                }
+            }
+            customActions = buildList {
+                if (page.canGoPrevious) add(CustomAccessibilityAction(previousPageDescription) {
+                    accessibilityPageTurn(ReaderTurnDirection.PREVIOUS)
+                    true
+                })
+                if (page.canGoNext) add(CustomAccessibilityAction(nextPageDescription) {
+                    accessibilityPageTurn(ReaderTurnDirection.NEXT)
+                    true
+                })
+            }
+        }
+    }.clipToBounds().background(backgroundColor).pointerInput(transitionMode, current.widthPx, current.heightPx, configuredTouchSlopPx) {
+        val pageTouchSlop = ReaderGestureSettingsPolicy.touchSlopPx(
+            viewConfiguration.touchSlop,
+            configuredTouchSlopPx,
+        )
+        awaitEachGesture {
+            val down = awaitFirstDown(requireUnconsumed = false)
+            latestReaderInteraction()
+            pageMotionJob?.cancel()
+            completePendingTurn()
+            displayOffset = 0f
+            transition = ReaderPageTransition()
+            bookmarkReturnJob?.cancel()
+            bookmarkOffset = 0f
+            bookmarkArmed = false
+            bookmarkWillRemove = latestHasBookmark()
+            val bookmarkEnabled = latestSwipeToBookmarkEnabled && textSelection == null
+            curlTouchY = down.position.y
+            val velocityTracker = VelocityTracker().also { it.addPosition(down.uptimeMillis, down.position) }
+            var total = Offset.Zero
+            var lastHorizontalDelta = 0f
+            var horizontalTurn = false
+            var horizontalDrag: ReaderHorizontalDrag? = null
+            var horizontalCapturedY = down.position.y
+            var bookmarkDrag = false
+            var bookmarkReleased = false
+            var scrollDrag = false
+            var movedPastSlop = false
+            var longPressed = false
+            var grabbingStart = false
+            var grabbingEnd = false
+            var grabbedEndpoint: ReaderSelectionEndpoint? = null
+            var suppressTap = false
+            var pointerPosition = down.position
+            val downSelectionLayout = pageViewportLayout()
+            val downPlacement = downSelectionLayout.pageAt(down.position.x, down.position.y)
+            val downPage = downPlacement?.page ?: latestPages.current
+            val downPageY = downPlacement?.localY(down.position.y) ?: down.position.y
+            textSelection?.let { selection ->
+                val bounds = downSelectionLayout.selectionBounds(selection).map { it.bounds }
+                val handleRadius = 28f * density
+                val start = bounds.firstOrNull()
+                val end = bounds.lastOrNull()
+                grabbingStart = start != null && Offset(start.left, start.bottom).minus(down.position).getDistance() <= handleRadius
+                grabbingEnd = end != null && Offset(end.right, end.bottom).minus(down.position).getDistance() <= handleRadius
+                grabbedEndpoint = when {
+                    grabbingStart -> selection.visualStartEndpoint()
+                    grabbingEnd -> selection.visualEndEndpoint()
+                    else -> null
+                }
+                if (!grabbingStart && !grabbingEnd) {
+                    textSelection = null
+                    dismissSelectionMenu()
+                    suppressTap = true
+                } 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
+                                if (latestSelectionHapticsEnabled) {
+                                    hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+                                }
+                                longPressed = true
+                            }
+                        }
+                    }
+                }
+            }
+            var released = false
+            try {
+                while (true) {
+                    val event = awaitPointerEvent(PointerEventPass.Main)
+                    val change = event.changes.firstOrNull { it.id == down.id } ?: break
+                    if (change.isConsumed) break
+                    pointerPosition = change.position
+                    if (!change.pressed) { released = true; break }
+                    total += change.positionChange()
+                    if (change.positionChange().x != 0f) lastHorizontalDelta = change.positionChange().x
+                    curlTouchY = change.position.y
+                    velocityTracker.addPosition(change.uptimeMillis, change.position)
+                    if (total.getDistance() >= pageTouchSlop) {
+                        movedPastSlop = true
+                        if (!longPressed && !grabbingStart && !grabbingEnd) longPressJob.cancel()
+                    }
+                    if (longPressed || grabbingStart || grabbingEnd) {
+                        val selection = textSelection
+                        val placement = pageViewportLayout().pageAt(change.position.x, change.position.y)
+                        if (placement != null && selection != null) {
+                            val page = placement.page
+                            // 圆把手视作竖线的延伸:手指落在把手圆区域内时按本行底边命中,
+                            // 避免圆与下一行之间的间隙把手柄拖动吸到下一行。
+                            var viewportY = change.position.y
+                            if (grabbedEndpoint != null) {
+                                val endpointBounds = pageViewportLayout().selectionBounds(selection).map { it.bounds }
+                                val anchorBound = if (grabbingStart) endpointBounds.firstOrNull() else endpointBounds.lastOrNull()
+                                if (anchorBound != null) {
+                                    val zoneBottom = anchorBound.bottom + 2 * SelectionHandleRadius.toPx()
+                                    if (viewportY > anchorBound.bottom && viewportY <= zoneBottom) {
+                                        viewportY = anchorBound.bottom
+                                    }
+                                }
+                            }
+                            val pageY = placement.localY(viewportY)
+                            val hit = ReaderSelectionPolicy.start(page, change.position.x, pageY)
+                                ?: if (grabbedEndpoint != null) {
+                                    ReaderSelectionPolicy.snapToText(page, change.position.x, pageY)?.let {
+                                        ReaderSelection(page.id.chapterIndex, it.chapterPosition, it.chapterPosition, it.emphasized)
+                                    }
+                                } else {
+                                    null
+                                }
+                            if (hit != null && hit.chapterIndex == selection.chapterIndex) {
+                                val updatedSelection = when {
+                                    grabbedEndpoint != null -> selection.moveEndpoint(
+                                        grabbedEndpoint,
+                                        hit.anchor,
+                                        hit.anchorIsTitle,
+                                    )
+                                    else -> ReaderSelectionPolicy.extend(selection, page, change.position.x, pageY)
+                                }
+                                if (updatedSelection != selection) {
+                                    textSelection = updatedSelection
+                                    if (latestSelectionHapticsEnabled) {
+                                        hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
+                                    }
+                                }
+                            }
+                            change.consume()
+                        }
+                        continue
+                    }
+                    if (!horizontalTurn && !bookmarkDrag && !scrollDrag && total.getDistance() >= pageTouchSlop) {
+                        val pull = pullBookmark(total, size.height.toFloat(), density, transitionMode, bookmarkEnabled)
+                        if (!bookmarkReleased) {
+                            val claim = PullBookmarkGesture.claim(bookmarkReleased, pull)
+                            bookmarkDrag = claim.isDragging
+                            // Match the View reader: once the first post-slop sample is not a
+                            // pull candidate, this gesture belongs to page turning/scrolling and
+                            // must not be reclaimed by a later diagonal direction change.
+                            bookmarkReleased = claim.isReleased
+                        }
+                        scrollDrag = transitionMode == ReaderTransitionMode.SCROLL &&
+                            ReaderMainAxisPolicy.isVerticalDominant(total.x, total.y)
+                        horizontalTurn = !bookmarkDrag && !scrollDrag &&
+                            ReaderMainAxisPolicy.isHorizontalDominant(total.x, total.y)
+                        if (horizontalTurn) {
+                            horizontalDrag = ReaderHorizontalDrag.capture(total.x)
+                            horizontalCapturedY = change.position.y
+                        }
+                    }
+                    if (bookmarkDrag) {
+                        val pull = pullBookmark(total, size.height.toFloat(), density, transitionMode, bookmarkEnabled)
+                        bookmarkOffset = pull.pageOffsetPx
+                        bookmarkArmed = pull.isArmed
+                        if (pull.isCandidate) {
+                            change.consume()
+                        } else {
+                            // Once handed to page turning, this gesture must never reclaim the pull.
+                            bookmarkDrag = false
+                            bookmarkReleased = true
+                            // The legacy reader restores curPage before forwarding MOVE to its
+                            // page delegate. Leaving this translation in place makes cover/slide
+                            // turns travel diagonally and can persist until the next DOWN.
+                            bookmarkArmed = false
+                            bookmarkOffset = 0f
+                            horizontalTurn = ReaderMainAxisPolicy.isHorizontalDominant(total.x, total.y)
+                            if (horizontalTurn) {
+                                horizontalDrag = ReaderHorizontalDrag.capture(total.x)
+                                horizontalCapturedY = change.position.y
+                            }
+                        }
+                    }
+                    if (horizontalTurn && transitionMode != ReaderTransitionMode.SCROLL) {
+                        transition = horizontalDrag?.transition(
+                            total.x, size.width.toFloat(),
+                            latestPages.previous != null, latestPages.next != null,
+                        ) ?: ReaderPageTransition(pageExtentPx = size.width.toFloat())
+                        transition.direction?.takeIf { transitionMode == ReaderTransitionMode.SIMULATION }?.let {
+                            curlTouchX = change.position.x
+                            curlCornerY = ReaderCurlTouchPolicy.cornerY(
+                                it, horizontalCapturedY, size.height.toFloat(),
+                            )
+                            curlTouchY = ReaderCurlTouchPolicy.dragY(
+                                it, horizontalCapturedY, change.position.y, size.height.toFloat(),
+                            )
+                        }
+                        displayOffset = transition.offsetPx
+                        change.consume()
+                    } else if (scrollDrag) {
+                        val window = latestPages
+                        val page = window.current
+                        if (page != null) {
+                            val result = ReaderScrollPolicy.apply(scrollOffset, change.positionChange().y, window.previous?.scrollExtentPx ?: 0f, page.scrollExtentPx, page.scrollViewportExtentPx(), window.previous != null, window.next != null)
+                            applyScrollResult(result, window)
+                            change.consume()
+                        }
+                    }
+                }
+            } finally {
+                longPressJob.cancel()
+                if (!released) {
+                    bookmarkArmed = false
+                    bookmarkOffset = 0f
+                    displayOffset = 0f
+                    transition = ReaderPageTransition()
+                }
+            }
+            if (released) latestReaderInteraction()
+            if (longPressed || grabbingStart || grabbingEnd) {
+                val selection = textSelection
+                if (released && selection != null) {
+                    val window = latestPages
+                    showSelectionMenu(selection, window)
+                }
+                return@awaitEachGesture
+            }
+            if (bookmarkDrag) {
+                val releasePull = pullBookmark(
+                    pointerPosition - down.position,
+                    size.height.toFloat(),
+                    density,
+                    transitionMode,
+                    bookmarkEnabled,
+                )
+                if (released && releasePull.isCandidate) {
+                    bookmarkOffset = releasePull.pageOffsetPx
+                }
+                if (released && PullBookmarkGesture.shouldToggleOnRelease(bookmarkDrag, releasePull)) {
+                    latestToggleBookmark()
+                }
+                bookmarkArmed = false
+                bookmarkReturnJob = animationScope.launch {
+                    Animatable(bookmarkOffset).animateTo(
+                        0f, tween(PullBookmarkDefaults.RETURN_DURATION_MILLIS),
+                    ) { bookmarkOffset = value }
+                }
+            } else if (scrollDrag) {
+                val velocity = if (released) velocityTracker.calculateVelocity().y else 0f
+                pageMotionJob = animationScope.launch {
+                    var lastValue = 0f
+                    try {
+                        Animatable(0f).animateDecay(velocity, scrollDecay) {
+                            val delta = value - lastValue
+                            lastValue = value
+                            // A page crossing publishes a new window asynchronously. Do not send
+                            // the same crossing more than once while that window is arriving.
+                            if (scrollCarryTarget == null) {
+                                val window = latestPages
+                                val page = window.current ?: return@animateDecay
+                                val result = ReaderScrollPolicy.apply(
+                                    scrollOffset,
+                                    delta,
+                                    window.previous?.scrollExtentPx ?: 0f,
+                                    page.scrollExtentPx,
+                                    page.scrollViewportExtentPx(),
+                                    window.previous != null,
+                                    window.next != null,
+                                )
+                                applyScrollResult(result, window)
+                                if (result.hitBoundary) throw ReaderScrollBoundaryReached()
+                            }
+                        }
+                    } catch (_: ReaderScrollBoundaryReached) {
+                        // Reaching the first/last content boundary ends the fling immediately.
+                    }
+                }
+            } else if (horizontalTurn && transition.dragging) {
+                val fade = transitionMode == ReaderTransitionMode.FADE
+                settlePageTurn(ReaderPageTransitionPolicy.release(
+                    transition,
+                    velocityPxPerSecond = if (fade) 0f else velocityTracker.calculateVelocity().x,
+                    commitProgress = if (fade) 0.1f else 0.35f,
+                    cancelled = !released,
+                    lastDragDeltaPx = if (fade) null else lastHorizontalDelta,
+                ))
+            } else if (released && !suppressTap && total.getDistance() < pageTouchSlop) {
+                val tapPlacement = pageViewportLayout().pageAt(down.position.x, down.position.y)
+                val elementHandled = tapPlacement?.page
+                    ?.elementAt(down.position.x, tapPlacement.localY(down.position.y))
+                    ?.let(onElementClick) == true
+                if (elementHandled) {
+                    // Element actions take precedence over reader tap zones.
+                } else dispatchTapAction(
+                    latestTapActionGrid.actionAt(
+                        down.position.x,
+                        down.position.y,
+                        size.width.toFloat(),
+                        size.height.toFloat(),
+                    )
+                )
+            }
+        }
+    }) {
+        if (ReaderViewportLayerPolicy.usesFixedBackground(transitionMode)) {
+            ReaderBackgroundSurface(
+                pageBackgroundImage,
+                backgroundImageAlpha,
+                Modifier.fillMaxSize(),
+            )
+        }
+        if (transitionMode == ReaderTransitionMode.SCROLL) {
+            Box(Modifier.fillMaxSize().drawWithContent {
+                clipRect(top = current.contentTopPx, bottom = current.contentBottomPx) {
+                    this@drawWithContent.drawContent()
+                }
+            }) {
+                ScrollPageStack(pages, scrollOffset, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor, textSelection, cachedImage, loadImage)
+            }
+        } else if (transitionMode == ReaderTransitionMode.SIMULATION && transition.direction != null) {
+            SimulationPageStack(pages, transition.direction!!, curlTouchX, curlTouchY, curlCornerY, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor, textSelection, cachedImage, loadImage)
+        } else {
+            @Composable
+            fun PageLayer(page: ReaderPage, transform: ReaderPageTransform, offsetY: Float = 0f) {
+                ReaderPageCanvas(
+                    page, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor,
+                    Modifier.fillMaxSize().graphicsLayer {
+                        translationX = transform.translationX
+                        translationY = offsetY + transform.translationY
+                        alpha = transform.alpha
+                    },
+                    textSelection,
+                    cachedImage,
+                    loadImage,
+                )
+            }
+            if (transforms.currentOnTop) {
+                transforms.next?.let { t -> pages.next?.let { PageLayer(it, t) } }
+            }
+            PageLayer(current, transforms.current, bookmarkOffset)
+            transforms.previous?.let { t -> pages.previous?.let { PageLayer(it, t) } }
+            if (!transforms.currentOnTop) {
+                transforms.next?.let { t -> pages.next?.let { PageLayer(it, t) } }
+            }
+        }
+        if (transitionMode == ReaderTransitionMode.COVER && transition.direction != null && displayOffset != 0f) {
+            Canvas(Modifier.fillMaxSize()) {
+                val direction = transition.direction ?: return@Canvas
+                val edge = ReaderCoverShadowPolicy.edgePx(direction, displayOffset, size.width)
+                val shadowWidth = ReaderCoverShadowPolicy.widthDp * density
+                val dark = Color(ReaderCoverShadowPolicy.colorArgb)
+                drawRect(
+                    Brush.horizontalGradient(
+                        listOf(dark, Color.Transparent),
+                        edge,
+                        edge + shadowWidth,
+                    ),
+                    Offset(edge, 0f),
+                    Size(shadowWidth, size.height),
+                )
+            }
+        }
+        if (transitionMode != ReaderTransitionMode.SCROLL && autoPageActive && autoRevealPx > 0f) {
+            pages.next?.let { page ->
+                Box(Modifier.fillMaxSize().drawWithContent {
+                    clipRect(bottom = autoRevealPx.coerceAtMost(size.height)) { this@drawWithContent.drawContent() }
+                }) {
+                    ReaderPageCanvas(page, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor, Modifier.fillMaxSize(), textSelection, cachedImage, loadImage)
+                }
+            }
+            Canvas(Modifier.fillMaxSize()) {
+                drawRect(
+                    color = autoPageIndicatorColor,
+                    topLeft = Offset(
+                        0f,
+                        ReaderAutoPagePolicy.indicatorTopPx(autoRevealPx, size.height),
+                    ),
+                    size = Size(size.width, 1f),
+                )
+            }
+        }
+        if (ReaderViewportLayerPolicy.usesFixedPageChrome(transitionMode)) {
+            ReaderPageDecorationOverlay(current, Modifier.fillMaxSize())
+        }
+        textSelection?.let { selection ->
+            val bounds = pageViewportLayout(pages).selectionBounds(selection).map { it.bounds }
+            val handleColor = selectionColor.copy(alpha = 1f)
+            val screenDensity = LocalDensity.current.density
+            val handleShadowPaint = remember(screenDensity) {
+                Paint().apply {
+                    isAntiAlias = true
+                    color = Color.White.toArgb()
+                    setShadowLayer(4f * screenDensity, 0f, 2f * screenDensity, 0x40000000)
+                }
+            }
+            Canvas(Modifier.fillMaxSize()) {
+                clipRect(
+                    top = if (transitionMode == ReaderTransitionMode.SCROLL) current.contentTopPx else 0f,
+                    bottom = if (transitionMode == ReaderTransitionMode.SCROLL) current.contentBottomPx else size.height,
+                ) {
+                    fun drawPinHandle(x: Float, top: Float, bottom: Float) {
+                        val lineWidth = SelectionHandleStrokeWidth.toPx()
+                        val radius = SelectionHandleRadius.toPx()
+                        // 竖线止于行底部,圆的顶端圆周与竖线末端相切
+                        val center = Offset(x, bottom + radius)
+                        drawLine(handleColor, Offset(x, top), Offset(x, bottom), lineWidth, StrokeCap.Round)
+                        drawIntoCanvas { it.nativeCanvas.drawCircle(x, center.y, radius, handleShadowPaint) }
+                        drawCircle(handleColor, radius, center, style = Stroke(SelectionHandleStrokeWidth.toPx()))
+                    }
+                    bounds.firstOrNull()?.let { rect -> drawPinHandle(rect.left, rect.top, rect.bottom) }
+                    bounds.lastOrNull()?.let { rect -> drawPinHandle(rect.right, rect.top, rect.bottom) }
+                }
+            }
+        }
+        AnimatedVisibility(
+            visible = bookmarkArmed,
+            modifier = Modifier.align(Alignment.TopCenter).offset {
+                IntOffset(0, current.contentTopPx.roundToInt())
+            }.padding(top = PullBookmarkDefaults.HINT_CONTENT_TOP_MARGIN_DP.dp),
+            enter = fadeIn(tween(PullBookmarkDefaults.HINT_FADE_MILLIS)),
+            exit = fadeOut(tween(0)),
+        ) {
+            Text(
+                text = stringResource(if (bookmarkWillRemove) {
+                    R.string.bookmark_swipe_release_to_remove
+                } else R.string.bookmark_swipe_release_to_add),
+                color = Color.White,
+                fontSize = 14.sp,
+                modifier = Modifier.background(
+                    Color(0xA0000000),
+                    RoundedCornerShape(PullBookmarkDefaults.HINT_CORNER_RADIUS_DP.dp),
+                )
+                    .padding(horizontal = 16.dp, vertical = 8.dp),
+            )
+        }
+    }
+}
+
+private fun pullBookmark(offset: Offset, height: Float, density: Float, mode: ReaderTransitionMode, enabled: Boolean) =
+    PullBookmarkGesture.drag(
+        offset.x,
+        offset.y,
+        height,
+        enabled,
+        mode != ReaderTransitionMode.SCROLL,
+        false,
+        PullBookmarkDefaults.config(PullBookmarkDefaults.ACTIVATION_DISTANCE_DP * density),
+    )
+
+private class ReaderScrollBoundaryReached : CancellationException()
+
+private fun ReaderPage.scrollViewportExtentPx(): Float =
+    (contentBottomPx - contentTopPx).coerceAtLeast(1f)
+
+@Composable
+private fun ScrollPageStack(
+    pages: ReaderPageWindow,
+    offsetY: Float,
+    background: Color,
+    backgroundImage: Drawable?,
+    backgroundImageAlpha: Float,
+    selection: Color,
+    readAloud: Color,
+    activeSelection: ReaderSelection?,
+    cachedImage: (ReaderElement.Image) -> Bitmap?,
+    loadImage: suspend (ReaderElement.Image) -> Bitmap?,
+) {
+    val current = pages.current ?: return
+    val density = LocalDensity.current
+    fun Modifier.pageExtent(page: ReaderPage) = fillMaxWidth().height(with(density) {
+        maxOf(page.heightPx.toFloat(), page.contentTopPx + page.scrollExtentPx).toDp()
+    })
+    pages.previous?.let { previous ->
+        ReaderPageCanvas(previous, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.pageExtent(previous).offset { IntOffset(0, (offsetY - previous.scrollExtentPx).roundToInt()) }.graphicsLayer { }, activeSelection, cachedImage, loadImage, drawBackground = false, drawDecoration = false)
+    }
+    ReaderPageCanvas(current, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.pageExtent(current).offset { IntOffset(0, offsetY.roundToInt()) }.graphicsLayer { }, activeSelection, cachedImage, loadImage, drawBackground = false, drawDecoration = false)
+    pages.next?.let { next ->
+        ReaderPageCanvas(next, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.pageExtent(next).offset { IntOffset(0, (offsetY + current.scrollExtentPx).roundToInt()) }.graphicsLayer { }, activeSelection, cachedImage, loadImage, drawBackground = false, drawDecoration = false)
+    }
+}
+
+@Composable
+private fun SimulationPageStack(
+    pages: ReaderPageWindow,
+    direction: ReaderTurnDirection,
+    touchX: Float,
+    touchY: Float,
+    cornerY: Float,
+    background: Color,
+    backgroundImage: Drawable?,
+    backgroundImageAlpha: Float,
+    selection: Color,
+    readAloud: Color,
+    activeSelection: ReaderSelection?,
+    cachedImage: (ReaderElement.Image) -> Bitmap?,
+    loadImage: suspend (ReaderElement.Image) -> Bitmap?,
+) {
+    val basePage = if (direction == ReaderTurnDirection.NEXT) pages.current else pages.previous
+    val revealPage = if (direction == ReaderTurnDirection.NEXT) pages.next else pages.current
+    if (basePage == null || revealPage == null) return
+    BoxWithConstraints(Modifier.fillMaxSize()) {
+        val density = LocalDensity.current
+        val width = with(density) { maxWidth.toPx() }
+        val height = with(density) { maxHeight.toPx() }
+        val frame = remember(width, height, touchX, touchY, cornerY) {
+            PageCurlGeometry.calculate(
+                width,
+                height,
+                touchX,
+                touchY.coerceIn(.1f, height - .1f),
+                lockedCorner = CurlPoint(width, cornerY),
+            )
+        }
+        if (frame == null) {
+            ReaderPageCanvas(basePage, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.fillMaxSize(), activeSelection, cachedImage, loadImage)
+            return@BoxWithConstraints
+        }
+        val paths = remember(frame, width, height) { frame.renderPaths(width, height) }
+        val path0 = paths.front
+        val pathNext = paths.reveal
+        val pathBack = paths.back
+        val baseLayer = rememberGraphicsLayer()
+        Box(Modifier.fillMaxSize().drawWithContent {
+            baseLayer.record { this@drawWithContent.drawContent() }
+            clipPath(path0, ClipOp.Difference) { drawLayer(baseLayer) }
+        }) {
+            ReaderPageCanvas(basePage, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.fillMaxSize(), activeSelection, cachedImage, loadImage)
+        }
+        Box(Modifier.fillMaxSize().drawWithContent {
+            clipPath(path0) { clipPath(pathNext) {
+                this@drawWithContent.drawContent()
+                drawCurlBackShadow(frame)
+            } }
+        }) {
+            ReaderPageCanvas(revealPage, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.fillMaxSize(), activeSelection, cachedImage, loadImage)
+        }
+        Canvas(Modifier.fillMaxSize()) {
+            clipPath(path0) { clipPath(pathBack) {
+                drawRect(background)
+                val matrix = Matrix().apply {
+                    values[Matrix.ScaleX] = frame.mirror.scaleX
+                    values[Matrix.SkewX] = frame.mirror.skewX
+                    values[Matrix.SkewY] = frame.mirror.skewY
+                    values[Matrix.ScaleY] = frame.mirror.scaleY
+                    values[Matrix.TranslateX] = frame.mirror.translateX
+                    values[Matrix.TranslateY] = frame.mirror.translateY
+                }
+                withTransform({ transform(matrix) }) { drawLayer(baseLayer) }
+                drawCurlFolderShadow(frame)
+            } }
+            drawCurlFrontShadows(frame, paths)
+        }
+    }
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurlFrontShadows(
+    frame: PageCurlFrame,
+    paths: ReaderCurlRenderPaths,
+) {
+    val curlPath = paths.front
+    val reverse = frame.corner.x == 0f && frame.corner.y == size.height || frame.corner.x == size.width && frame.corner.y == 0f
+    clipPath(curlPath, ClipOp.Difference) { clipPath(paths.frontShadowHorizontal) {
+        val left = if (reverse) frame.control1.x else frame.control1.x - 25f
+        val right = if (reverse) frame.control1.x + 25f else frame.control1.x + 1f
+        val rotation = (atan2((frame.touch.x - frame.control1.x).toDouble(), (frame.control1.y - frame.touch.y).toDouble()) * 180.0 / PI).toFloat()
+        withTransform({ rotate(rotation, Offset(frame.control1.x, frame.control1.y)) }) {
+            drawRect(Brush.horizontalGradient(if (reverse) listOf(Color(ReaderCurlVisualPolicy.frontShadowDarkArgb), Color.Transparent) else listOf(Color.Transparent, Color(ReaderCurlVisualPolicy.frontShadowDarkArgb)), left, right), Offset(left, frame.control1.y - hypot(size.width.toDouble(), size.height.toDouble()).toFloat()), Size(right - left, hypot(size.width.toDouble(), size.height.toDouble()).toFloat()))
+        }
+    } }
+    clipPath(curlPath, ClipOp.Difference) { clipPath(paths.frontShadowVertical) {
+        val top = if (reverse) frame.control2.y else frame.control2.y - 25f
+        val bottom = if (reverse) frame.control2.y + 25f else frame.control2.y + 1f
+        val rotation = (atan2((frame.control2.y - frame.touch.y).toDouble(), (frame.control2.x - frame.touch.x).toDouble()) * 180.0 / PI).toFloat()
+        val diagonal = hypot(size.width.toDouble(), size.height.toDouble()).toFloat()
+        val adjustedY = if (frame.control2.y < 0f) frame.control2.y - size.height else frame.control2.y
+        val hmg = hypot(frame.control2.x.toDouble(), adjustedY.toDouble()).toFloat()
+        val left = if (hmg > diagonal) frame.control2.x - 25f - hmg else frame.control2.x - diagonal
+        val right = if (hmg > diagonal) frame.control2.x + diagonal - hmg else frame.control2.x
+        withTransform({ rotate(rotation, Offset(frame.control2.x, frame.control2.y)) }) {
+            drawRect(Brush.verticalGradient(if (reverse) listOf(Color(ReaderCurlVisualPolicy.frontShadowDarkArgb), Color.Transparent) else listOf(Color.Transparent, Color(ReaderCurlVisualPolicy.frontShadowDarkArgb)), top, bottom), Offset(left, top), Size(right - left, bottom - top))
+        }
+    } }
+}
+
+private data class ReaderCurlRenderPaths(
+    val front: Path,
+    val reveal: Path,
+    val back: Path,
+    val frontShadowHorizontal: Path,
+    val frontShadowVertical: Path,
+)
+
+private fun PageCurlFrame.renderPaths(width: Float, height: Float): ReaderCurlRenderPaths {
+    val reverse = corner.x == 0f && corner.y == height || corner.x == width && corner.y == 0f
+    val angle = if (reverse) {
+        PI / 4 - atan2((control1.y - touch.y).toDouble(), (touch.x - control1.x).toDouble())
+    } else {
+        PI / 4 - atan2((touch.y - control1.y).toDouble(), (touch.x - control1.x).toDouble())
+    }
+    val shadowX = (touch.x + 25f * 1.414f * cos(angle)).toFloat()
+    val shadowY = (touch.y + (if (reverse) 1 else -1) * 25f * 1.414f * sin(angle)).toFloat()
+    return ReaderCurlRenderPaths(
+        front = Path().apply {
+            moveTo(start1.x, start1.y); quadraticTo(control1.x, control1.y, end1.x, end1.y)
+            lineTo(touch.x, touch.y); lineTo(end2.x, end2.y)
+            quadraticTo(control2.x, control2.y, start2.x, start2.y); lineTo(corner.x, corner.y); close()
+        },
+        reveal = Path().apply {
+            moveTo(start1.x, start1.y); lineTo(vertex1.x, vertex1.y); lineTo(vertex2.x, vertex2.y)
+            lineTo(start2.x, start2.y); lineTo(corner.x, corner.y); close()
+        },
+        back = Path().apply {
+            moveTo(vertex2.x, vertex2.y); lineTo(vertex1.x, vertex1.y); lineTo(end1.x, end1.y)
+            lineTo(touch.x, touch.y); lineTo(end2.x, end2.y); close()
+        },
+        frontShadowHorizontal = Path().apply {
+            moveTo(shadowX, shadowY); lineTo(touch.x, touch.y); lineTo(control1.x, control1.y)
+            lineTo(start1.x, start1.y); close()
+        },
+        frontShadowVertical = Path().apply {
+            moveTo(shadowX, shadowY); lineTo(touch.x, touch.y); lineTo(control2.x, control2.y)
+            lineTo(start2.x, start2.y); close()
+        },
+    )
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurlBackShadow(frame: PageCurlFrame) {
+    val reverse = frame.corner.x == 0f && frame.corner.y == size.height || frame.corner.x == size.width && frame.corner.y == 0f
+    val shadowWidth = frame.touchToCornerDistance / 4f
+    val left = if (reverse) frame.start1.x else frame.start1.x - shadowWidth
+    val right = if (reverse) frame.start1.x + shadowWidth else frame.start1.x
+    if (right <= left) return
+    withTransform({ rotate(frame.degrees, Offset(frame.start1.x, frame.start1.y)) }) {
+        drawRect(Brush.horizontalGradient(if (reverse) listOf(Color(ReaderCurlVisualPolicy.backShadowDarkArgb), Color.Transparent) else listOf(Color.Transparent, Color(ReaderCurlVisualPolicy.backShadowDarkArgb)), left, right), Offset(left, frame.start1.y), Size(right - left, hypot(size.width.toDouble(), size.height.toDouble()).toFloat()))
+    }
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCurlFolderShadow(frame: PageCurlFrame) {
+    val width = minOf(abs((frame.start1.x - frame.control1.x) / 2f), abs((frame.start2.y - frame.control2.y) / 2f)).coerceAtLeast(1f)
+    val reverse = frame.corner.x == 0f && frame.corner.y == size.height || frame.corner.x == size.width && frame.corner.y == 0f
+    val left = if (reverse) frame.start1.x - 1f else frame.start1.x - width - 1f
+    val right = if (reverse) frame.start1.x + width + 1f else frame.start1.x + 1f
+    withTransform({ rotate(frame.degrees, Offset(frame.start1.x, frame.start1.y)) }) {
+        drawRect(Brush.horizontalGradient(if (reverse) listOf(Color.Transparent, Color(ReaderCurlVisualPolicy.folderShadowDarkArgb)) else listOf(Color(ReaderCurlVisualPolicy.folderShadowDarkArgb), Color.Transparent), left, right), Offset(left, frame.start1.y), Size(right - left, hypot(size.width.toDouble(), size.height.toDouble()).toFloat()))
+    }
+}
+
+@Composable
+private fun ReaderPageCanvas(
+    page: ReaderPage,
+    background: Color,
+    backgroundImage: Drawable?,
+    backgroundImageAlpha: Float,
+    selection: Color,
+    readAloud: Color,
+    modifier: Modifier,
+    activeSelection: ReaderSelection?,
+    cachedImage: (ReaderElement.Image) -> Bitmap?,
+    loadImage: suspend (ReaderElement.Image) -> Bitmap?,
+    drawBackground: Boolean = true,
+    drawDecoration: Boolean = true,
+) {
+    val isolatedBackgroundImage = remember(backgroundImage) { backgroundImage?.isolatedCopy() }
+    val textElements = remember(page.elements) {
+        page.elements.filterIsInstance<ReaderElement.Text>()
+    }
+    val paints = remember(textElements) {
+        textElements.map { it.style }.distinct()
+            .associateWith(ReaderAndroidPaintFactory::create)
+    }
+    val badge = page.decoration.bookmarkBadge.takeIf { drawDecoration }
+    val cachedBadgeImage = badge?.let(ReaderBookmarkBadgeRenderer::cachedResult)
+    val badgeImage by produceState(cachedBadgeImage, badge) {
+        value = badge?.let { it to withContext(Dispatchers.IO) { ReaderBookmarkBadgeRenderer.load(it) } }
+    }
+    val pageImages = page.elements.filterIsInstance<ReaderElement.Image>()
+    val cachedImages = pageImages.mapNotNull { element ->
+        cachedImage(element)?.takeUnless(Bitmap::isRecycled)?.let { element to it }
+    }.toMap()
+    var images by remember(page.id, page.layoutRevision, page.revision, pageImages) {
+        mutableStateOf(cachedImages)
+    }
+    LaunchedEffect(page.id, page.layoutRevision, page.revision, pageImages) {
+        pageImages.forEach { element ->
+            val bitmap = (cachedImage(element) ?: loadImage(element))
+                ?.takeUnless(Bitmap::isRecycled)
+                ?: return@forEach
+            if (images[element] !== bitmap) {
+                images = images + (element to bitmap)
+            }
+        }
+    }
+    val textBackgrounds = remember(page.elements) { page.textBackgroundRuns() }
+    val textBackgroundPaint = remember {
+        Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
+    }
+    val decorationDrawCache = remember(page.elements) {
+        ReaderPageDecorationDrawCache.create(page)
+    }
+    val selectedTextBounds = remember(textElements, activeSelection) {
+        textElements
+            .filter { it.selected || activeSelection?.contains(it) == true }
+            .map(ReaderElement.Text::bounds)
+            .mergeSelectionBounds()
+    }
+    val textBackgroundSources = remember(textBackgrounds) {
+        textBackgrounds.map { it.image.source }.distinct()
+    }
+    val cachedTextBackgrounds = textBackgroundSources.mapNotNull { source ->
+        ReaderTextBackgroundLoader.cached(source)?.let { source to it }
+    }.toMap()
+    val textBackgroundBitmaps by produceState(cachedTextBackgrounds, textBackgroundSources) {
+        value = withContext(Dispatchers.IO) {
+            textBackgroundSources.mapNotNull { source ->
+                ReaderTextBackgroundLoader.load(source)?.let { source to it }
+            }.toMap()
+        }
+    }
+    val tipPaints = remember(
+        page.decoration.header,
+        page.decoration.footer,
+        drawDecoration,
+    ) {
+        if (!drawDecoration) emptyMap() else listOfNotNull(page.decoration.header, page.decoration.footer).associateWith { row ->
+            Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
+                color = row.colorArgb
+                textSize = row.fontSizePx
+                typeface = ReaderAndroidPaintFactory.loadTypeface(row.fontPath, 400, false)
+            }
+        }
+    }
+    Canvas(if (drawBackground) modifier.background(background) else modifier) {
+        val native = drawContext.canvas.nativeCanvas
+        isolatedBackgroundImage?.takeIf { drawBackground }?.let { drawable ->
+            drawable.bounds = android.graphics.Rect(0, 0, size.width.roundToInt(), size.height.roundToInt())
+            drawable.alpha = (backgroundImageAlpha.coerceIn(0f, 1f) * 255).roundToInt()
+            drawable.draw(native)
+        }
+        textBackgrounds.forEach { run ->
+            textBackgroundBitmaps[run.image.source]?.let { bitmap ->
+                drawTextBackground(native, bitmap, run, textBackgroundPaint)
+            }
+        }
+        textElements.forEach { element ->
+            element.style.backgroundArgb?.let { color ->
+                drawRect(
+                    Color(color),
+                    Offset(element.bounds.left, element.bounds.top),
+                    Size(element.bounds.width, element.bounds.height),
+                )
+            }
+        }
+        selectedTextBounds.forEach { rect ->
+            drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
+        }
+        page.elements.forEach { e -> when (e) {
+            is ReaderElement.Text -> {
+                val paint = paints.getValue(e.style)
+                paint.color = e.resolvedColorArgb(readAloud.toArgb())
+                paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
+                native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
+            }
+            is ReaderElement.Image -> images[e]?.let { bitmap ->
+                ReaderImageDrawLayout.fitCenter(e.bounds, bitmap.width, bitmap.height)?.let { layout ->
+                    drawImage(
+                        image = bitmap.asImageBitmap(),
+                        dstOffset = IntOffset(layout.leftPx.roundToInt(), layout.topPx.roundToInt()),
+                        dstSize = IntSize(
+                            layout.widthPx.roundToInt().coerceAtLeast(1),
+                            layout.heightPx.roundToInt().coerceAtLeast(1),
+                        ),
+                    )
+                }
+            } ?: drawRect(Color.Gray.copy(alpha = .18f), Offset(e.bounds.left, e.bounds.top), Size(e.bounds.width, e.bounds.height))
+            is ReaderElement.Review -> if (e.count > 0) drawReview(native, e, paints.values.firstOrNull()?.color ?: android.graphics.Color.GRAY)
+            is ReaderElement.Action -> Unit
+            is ReaderElement.Spacer -> Unit
+            is ReaderElement.ParagraphMarker -> {
+                if (e.circular) {
+                    drawCircle(Color(e.colorArgb), e.strokeWidthPx / 2f, Offset(e.bounds.left, e.bounds.top))
+                } else {
+                    drawLine(
+                        Color(e.colorArgb),
+                        Offset(e.bounds.left, e.bounds.top),
+                        Offset(e.bounds.right, e.bounds.bottom),
+                        e.strokeWidthPx,
+                    )
+                }
+            }
+            is ReaderElement.Rule -> Unit
+        } }
+        page.emphasisUnderlineRuns().forEach { run ->
+            drawLine(
+                color = Color(run.style.colorArgb),
+                start = Offset(run.startPx, run.yPx),
+                end = Offset(run.endPx, run.yPx),
+                strokeWidth = run.style.widthPx,
+            )
+        }
+        decorationDrawCache.contentRules.forEach { it.draw(native) }
+        decorationDrawCache.styledUnderlines.forEach { it.draw(native) }
+        decorationDrawCache.overlayRules.forEach { it.draw(native) }
+        if (drawDecoration) drawPageDecoration(native, page, tipPaints, badgeImage)
+    }
+}
+
+@Composable
+fun ReaderBackgroundSurface(
+    backgroundImage: Drawable?,
+    backgroundImageAlpha: Float,
+    modifier: Modifier,
+    animateAppearance: Boolean = false,
+) {
+    val isolatedBackgroundImage = remember(backgroundImage) { backgroundImage?.isolatedCopy() }
+    val targetAlpha = if (isolatedBackgroundImage == null) 0f else backgroundImageAlpha
+    val animatedAlpha by animateFloatAsState(
+        targetValue = targetAlpha,
+        animationSpec = if (animateAppearance) tween(450) else snap(),
+        label = "readerBackgroundAlpha",
+    )
+    Canvas(modifier) {
+        isolatedBackgroundImage?.let { drawable ->
+            drawable.bounds = android.graphics.Rect(0, 0, size.width.roundToInt(), size.height.roundToInt())
+            drawable.alpha = (animatedAlpha.coerceIn(0f, 1f) * 255).roundToInt()
+            drawable.draw(drawContext.canvas.nativeCanvas)
+        }
+    }
+}
+
+private fun Drawable.isolatedCopy(): Drawable =
+    constantState?.newDrawable()?.mutate() ?: mutate()
+
+@Composable
+private fun ReaderPageDecorationOverlay(page: ReaderPage, modifier: Modifier) {
+    val badge = page.decoration.bookmarkBadge
+    val cachedBadgeImage = badge?.let(ReaderBookmarkBadgeRenderer::cachedResult)
+    val badgeImage by produceState(cachedBadgeImage, badge) {
+        value = badge?.let { it to withContext(Dispatchers.IO) { ReaderBookmarkBadgeRenderer.load(it) } }
+    }
+    val tipPaints = remember(page.decoration.header, page.decoration.footer) {
+        listOfNotNull(page.decoration.header, page.decoration.footer).associateWith(::createTipPaint)
+    }
+    Canvas(modifier) {
+        drawPageDecoration(drawContext.canvas.nativeCanvas, page, tipPaints, badgeImage)
+    }
+}
+
+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)
+}
+
+internal fun <T> resolveReaderTipResource(
+    row: ReaderTipRow,
+    cached: Map<ReaderTipRow, T>,
+    create: (ReaderTipRow) -> T,
+): T = cached[row] ?: create(row)
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawPageDecoration(
+    canvas: android.graphics.Canvas,
+    page: ReaderPage,
+    tipPaints: Map<ReaderTipRow, Paint>,
+    badgeImage: Pair<io.legado.app.feature.reader.core.model.ReaderBookmarkBadge, Bitmap?>?,
+) {
+    page.decoration.header?.takeIf { it.visible }?.let { row ->
+        val paint = resolveReaderTipResource(row, tipPaints, ::createTipPaint)
+        drawTipRow(
+            canvas,
+            row,
+            paint,
+            ReaderTipRowLayout.headerBaseline(row.paddingTopPx, paint.fontMetrics.top),
+        )
+        row.dividerColorArgb?.let {
+            val metrics = paint.fontMetrics
+            val dividerY = ReaderTipRowLayout.extent(
+                row.paddingTopPx, metrics.top, metrics.bottom, row.paddingBottomPx,
+            )
+            drawLine(Color(it), Offset(0f, dividerY), Offset(size.width, dividerY), 1f)
+        }
+    }
+    page.decoration.footer?.takeIf { it.visible }?.let { row ->
+        val paint = resolveReaderTipResource(row, tipPaints, ::createTipPaint)
+        drawTipRow(
+            canvas,
+            row,
+            paint,
+            ReaderTipRowLayout.footerBaseline(
+                size.height, row.paddingBottomPx, paint.fontMetrics.bottom,
+            ),
+        )
+        row.dividerColorArgb?.let {
+            val metrics = paint.fontMetrics
+            val dividerY = size.height - ReaderTipRowLayout.extent(
+                row.paddingTopPx, metrics.top, metrics.bottom, row.paddingBottomPx,
+            )
+            drawLine(Color(it), Offset(0f, dividerY), Offset(size.width, dividerY), 1f)
+        }
+    }
+    page.decoration.bookmarkBadge?.let { badge ->
+        if (shouldDrawReaderBookmarkBadge(badge, badgeImage)) {
+            ReaderBookmarkBadgeRenderer.draw(
+                canvas,
+                badge,
+                badgeImage?.takeIf { it.first == badge }?.second,
+            )
+        }
+    }
+}
+
+internal fun shouldDrawReaderBookmarkBadge(
+    badge: io.legado.app.feature.reader.core.model.ReaderBookmarkBadge,
+    loaded: Pair<io.legado.app.feature.reader.core.model.ReaderBookmarkBadge, Bitmap?>?,
+): Boolean = badge.imageSource.isBlank() || loaded?.first == badge
+
+private fun drawTextBackground(
+    canvas: android.graphics.Canvas,
+    bitmap: Bitmap,
+    run: io.legado.app.feature.reader.core.model.ReaderTextBackgroundRun,
+    paint: Paint,
+) {
+    val bounds = run.contentBounds
+    val image = run.image
+    if (bounds.width <= 0f || bounds.height <= 0f) return
+    val destination = android.graphics.RectF(bounds.left, bounds.top, bounds.right, bounds.bottom)
+    // One paint is retained per page layer so page-turn frames do not allocate per styled run.
+    // A tiled predecessor leaves a shader behind, therefore always clear it before reusing it.
+    paint.shader = null
+    val scale = image.scale.coerceIn(0.1f, 5f)
+    when (image.fit) {
+        1 -> {
+            val width = bounds.width * scale
+            val height = bounds.height * scale
+            val rect = android.graphics.RectF(
+                bounds.left + (bounds.width - width) / 2f,
+                bounds.top + (bounds.height - height) / 2f,
+                bounds.left + (bounds.width + width) / 2f,
+                bounds.top + (bounds.height + height) / 2f,
+            )
+            canvas.save()
+            canvas.clipRect(destination)
+            canvas.drawBitmap(bitmap, null, rect, paint)
+            canvas.restore()
+        }
+        2 -> {
+            val fit = maxOf(bounds.width / bitmap.width, bounds.height / bitmap.height) * scale
+            val width = bitmap.width * fit
+            val height = bitmap.height * fit
+            val rect = android.graphics.RectF(
+                bounds.left + (bounds.width - width) / 2f,
+                bounds.top + (bounds.height - height) / 2f,
+                bounds.left + (bounds.width + width) / 2f,
+                bounds.top + (bounds.height + height) / 2f,
+            )
+            canvas.save()
+            canvas.clipRect(destination)
+            canvas.drawBitmap(bitmap, null, rect, paint)
+            canvas.restore()
+        }
+        3 -> drawNineSliceBackground(
+            canvas = canvas,
+            bitmap = bitmap,
+            content = destination,
+            frame = android.graphics.RectF(run.bounds.left, run.bounds.top, run.bounds.right, run.bounds.bottom),
+            image = image,
+            paint = paint,
+        )
+        else -> {
+            val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
+            val matrix = android.graphics.Matrix().apply {
+                setScale(scale, scale)
+                postTranslate(bounds.left, bounds.top)
+            }
+            shader.setLocalMatrix(matrix)
+            paint.shader = shader
+            canvas.drawRect(destination, paint)
+        }
+    }
+}
+
+private fun drawNineSliceBackground(
+    canvas: android.graphics.Canvas,
+    bitmap: Bitmap,
+    content: android.graphics.RectF,
+    frame: android.graphics.RectF,
+    image: ReaderTextBackgroundImage,
+    paint: Paint,
+) {
+    ReaderNineSliceLayout.cells(
+        bitmap.width,
+        bitmap.height,
+        ReaderRect(content.left, content.top, content.right, content.bottom),
+        ReaderRect(frame.left, frame.top, frame.right, frame.bottom),
+        image,
+    ).forEach { cell ->
+        canvas.drawBitmap(
+            bitmap,
+            android.graphics.Rect(cell.source.left, cell.source.top, cell.source.right, cell.source.bottom),
+            android.graphics.RectF(
+                cell.destination.left,
+                cell.destination.top,
+                cell.destination.right,
+                cell.destination.bottom,
+            ),
+            paint,
+        )
+    }
+}
+
+private fun drawReview(canvas: android.graphics.Canvas, review: ReaderElement.Review, colorArgb: Int) {
+    val start = review.bounds.left
+    val end = review.bounds.right
+    val baseline = review.baselinePx
+    val height = review.textSizePx
+    val path = android.graphics.Path().apply {
+        moveTo(start + 1f, baseline - height * 2f / 5f)
+        lineTo(start + height / 6f, baseline - height * .55f)
+        lineTo(start + height / 6f, baseline - height * .8f)
+        lineTo(end - 1f, baseline - height * .8f)
+        lineTo(end - 1f, baseline)
+        lineTo(start + height / 6f, baseline)
+        lineTo(start + height / 6f, baseline - height / 4f)
+        close()
+    }
+    val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = colorArgb
+        textSize = height * .55f
+        textAlign = Paint.Align.CENTER
+    }
+    paint.style = Paint.Style.STROKE
+    canvas.drawPath(path, paint)
+    paint.style = Paint.Style.FILL
+    canvas.drawText(review.count.coerceAtMost(999).toString(), (start + height / 9f + end) / 2f, baseline - height * .23f, paint)
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawTipRow(
+    canvas: android.graphics.Canvas,
+    row: ReaderTipRow,
+    paint: Paint,
+    baseline: Float,
+) {
+    row.tips.forEach { tip ->
+        if (tip.visual == ReaderTipVisual.TEXT) {
+            val x = when (tip.alignment) {
+                ReaderTipAlignment.START -> { paint.textAlign = Paint.Align.LEFT; row.paddingLeftPx }
+                ReaderTipAlignment.CENTER -> { paint.textAlign = Paint.Align.CENTER; size.width / 2f }
+                ReaderTipAlignment.END -> { paint.textAlign = Paint.Align.RIGHT; size.width - row.paddingRightPx }
+            }
+            canvas.drawText(tip.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: 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 = 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
+        ReaderTipVisual.BATTERY_CLASSIC -> (if (tip.text.isEmpty()) 0f else textWidth + gap) + numberWidth + 10f * unit
+        ReaderTipVisual.ARROW -> 12f * unit + 8f * unit + textWidth
+        ReaderTipVisual.TEXT -> textWidth
+    }
+    val left = when (tip.alignment) {
+        ReaderTipAlignment.START -> row.paddingLeftPx
+        ReaderTipAlignment.CENTER -> (size.width - visualWidth) / 2f
+        ReaderTipAlignment.END -> size.width - row.paddingRightPx - visualWidth
+    }
+    paint.textAlign = Paint.Align.LEFT
+    when (tip.visual) {
+        ReaderTipVisual.BATTERY_OUTER -> {
+            drawBatteryGlyph(canvas, left, baseline, tip.batteryPercent, paint, drawNumberInside = false)
+            canvas.drawText(number, left + batteryWidth + 2f * unit, baseline, paint)
+        }
+        ReaderTipVisual.BATTERY_INNER -> {
+            val batteryLeft = if (tip.text.isEmpty()) left else {
+                canvas.drawText(tip.text, left, baseline, paint)
+                left + textWidth + gap
+            }
+            drawBatteryGlyph(canvas, batteryLeft, baseline, tip.batteryPercent, paint, drawNumberInside = true)
+        }
+        ReaderTipVisual.BATTERY_ICON ->
+            drawBatteryGlyph(canvas, left, baseline, tip.batteryPercent, paint, drawNumberInside = false)
+        ReaderTipVisual.BATTERY_CLASSIC -> {
+            val numberLeft = if (tip.text.isEmpty()) left + 4f * unit else {
+                canvas.drawText(tip.text, left, baseline, paint)
+                left + textWidth + gap + 4f * unit
+            }
+            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,
+                top + (bottom - top) / 3f,
+                numberLeft + numberWidth + 4f * unit,
+                bottom - (bottom - top) / 3f,
+                Paint(paint).apply { style = Paint.Style.FILL },
+            )
+        }
+        ReaderTipVisual.ARROW -> {
+            val centerY = baseline + (paint.fontMetrics.ascent + paint.fontMetrics.descent) / 2f
+            val arrow = android.graphics.Path().apply {
+                moveTo(left + 8f * unit, centerY - 5f * unit)
+                lineTo(left + 3f * unit, centerY)
+                lineTo(left + 8f * unit, centerY + 5f * unit)
+            }
+            canvas.drawPath(arrow, Paint(paint).apply {
+                style = Paint.Style.STROKE
+                strokeWidth = 1.5f * unit
+                strokeCap = Paint.Cap.SQUARE
+                strokeJoin = Paint.Join.MITER
+            })
+            canvas.drawText(tip.text, left + 20f * unit, baseline, paint)
+        }
+        ReaderTipVisual.TEXT -> Unit
+    }
+}
+
+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawBatteryGlyph(
+    canvas: android.graphics.Canvas,
+    left: Float,
+    baseline: Float,
+    batteryPercent: Int,
+    paint: Paint,
+    drawNumberInside: Boolean,
+) {
+    val unit = density
+    val bodyWidth = 22f * unit
+    val bodyHeight = 10f * unit
+    val top = baseline + (paint.fontMetrics.ascent + paint.fontMetrics.descent) / 2f - bodyHeight / 2f
+    val bodyLeft = left + 2f * unit
+    val outline = Paint(paint).apply {
+        style = Paint.Style.STROKE
+        strokeWidth = unit.coerceAtLeast(1f)
+        alpha = 194
+    }
+    val fill = Paint(paint).apply { style = Paint.Style.FILL; alpha = 194 }
+    canvas.drawRoundRect(bodyLeft, top, bodyLeft + bodyWidth, top + bodyHeight, unit, unit, outline)
+    canvas.drawRect(
+        bodyLeft + bodyWidth,
+        top + bodyHeight / 3f,
+        bodyLeft + bodyWidth + 2f * unit,
+        top + bodyHeight * 2f / 3f,
+        fill,
+    )
+    val innerWidth = (bodyWidth - 4f * unit) * batteryPercent.coerceIn(0, 100) / 100f
+    if (innerWidth > 0f) {
+        canvas.drawRoundRect(
+            bodyLeft + 2f * unit,
+            top + 2f * unit,
+            bodyLeft + 2f * unit + innerWidth,
+            top + bodyHeight - 2f * unit,
+            unit,
+            unit,
+            fill,
+        )
+    }
+    if (drawNumberInside) {
+        val numberPaint = Paint(paint).apply {
+            textSize = minOf(textSize * .72f, 8f * unit)
+            textAlign = Paint.Align.CENTER
+            isFakeBoldText = true
+        }
+        val centerY = top + bodyHeight / 2f - (numberPaint.fontMetrics.ascent + numberPaint.fontMetrics.descent) / 2f
+        canvas.drawText(
+            batteryPercent.coerceIn(0, 100).toString(),
+            bodyLeft + bodyWidth / 2f,
+            centerY,
+            numberPaint,
+        )
+    }
+}

+ 22 - 0
app/src/main/java/io/legado/app/feature/reader/core/accessibility/ReaderAccessibilityPolicy.kt

@@ -0,0 +1,22 @@
+package io.legado.app.feature.reader.core.accessibility
+
+import io.legado.app.feature.reader.core.model.ReaderPageWindow
+
+data class ReaderAccessibilityPage(
+    val text: String,
+    val canGoPrevious: Boolean,
+    val canGoNext: Boolean,
+    val isBookmarked: Boolean,
+)
+
+object ReaderAccessibilityPolicy {
+    fun snapshot(window: ReaderPageWindow): ReaderAccessibilityPage? {
+        val current = window.current ?: return null
+        return ReaderAccessibilityPage(
+            text = current.text,
+            canGoPrevious = window.previous != null,
+            canGoNext = window.next != null,
+            isBookmarked = current.decoration.bookmarkBadge != null,
+        )
+    }
+}

+ 79 - 0
app/src/main/java/io/legado/app/feature/reader/core/gesture/PullBookmarkGesture.kt

@@ -0,0 +1,79 @@
+package io.legado.app.feature.reader.core.gesture
+
+import kotlin.math.abs
+
+data class PullBookmarkConfig(
+    val activationDistancePx: Float,
+    val verticalDominanceRatio: Float = PullBookmarkDefaults.VERTICAL_DOMINANCE_RATIO,
+    val maxPageOffsetRatio: Float = PullBookmarkDefaults.MAX_PAGE_OFFSET_RATIO,
+)
+
+/** One compatibility source for the legacy View host and the Compose reader surface. */
+object PullBookmarkDefaults {
+    const val ACTIVATION_DISTANCE_DP = 80f
+    const val VERTICAL_DOMINANCE_RATIO = 1.5f
+    const val MAX_PAGE_OFFSET_RATIO = 0.6f
+    const val HINT_CONTENT_TOP_MARGIN_DP = 8
+    const val HINT_FADE_MILLIS = 120
+    const val HINT_CORNER_RADIUS_DP = 24f
+    const val RETURN_DURATION_MILLIS = 180
+
+    fun config(activationDistancePx: Float) = PullBookmarkConfig(
+        activationDistancePx = activationDistancePx,
+        verticalDominanceRatio = VERTICAL_DOMINANCE_RATIO,
+        maxPageOffsetRatio = MAX_PAGE_OFFSET_RATIO,
+    )
+}
+
+data class PullBookmarkDrag(
+    val isCandidate: Boolean,
+    val pageOffsetPx: Float,
+    val isArmed: Boolean,
+)
+
+data class PullBookmarkClaim(
+    val isDragging: Boolean,
+    val isReleased: Boolean,
+)
+
+/** Pure gesture policy shared by the temporary View host and the new Compose pointer input. */
+object PullBookmarkGesture {
+    /** Release must use the pointer's final displacement, not the last MOVE sample. */
+    fun shouldToggleOnRelease(wasDragging: Boolean, finalDrag: PullBookmarkDrag): Boolean =
+        wasDragging && finalDrag.isCandidate && finalDrag.isArmed
+
+    /**
+     * Locks gesture ownership after a pull candidate has been rejected.
+     *
+     * The page-turn delegate may start animating as soon as ownership is released, so a later
+     * diagonal movement cannot safely reclaim the same pointer stream for bookmarking.
+     */
+    fun claim(previouslyReleased: Boolean, drag: PullBookmarkDrag): PullBookmarkClaim =
+        if (previouslyReleased || !drag.isCandidate) {
+            PullBookmarkClaim(isDragging = false, isReleased = true)
+        } else {
+            PullBookmarkClaim(isDragging = true, isReleased = false)
+        }
+
+    fun drag(
+        deltaX: Float,
+        deltaY: Float,
+        pageHeightPx: Float,
+        enabled: Boolean,
+        pagedMode: Boolean,
+        selectionActive: Boolean,
+        config: PullBookmarkConfig,
+    ): PullBookmarkDrag {
+        val candidate = enabled && pagedMode && !selectionActive && deltaY > 0f &&
+            abs(deltaY) > abs(deltaX) * config.verticalDominanceRatio
+        return PullBookmarkDrag(
+            isCandidate = candidate,
+            pageOffsetPx = if (candidate) {
+                deltaY.coerceIn(0f, pageHeightPx * config.maxPageOffsetRatio)
+            } else {
+                0f
+            },
+            isArmed = candidate && deltaY >= config.activationDistancePx,
+        )
+    }
+}

+ 11 - 0
app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderGestureSettingsPolicy.kt

@@ -0,0 +1,11 @@
+package io.legado.app.feature.reader.core.gesture
+
+object ReaderGestureSettingsPolicy {
+    /** The legacy preference is stored as raw pixels; zero delegates to the platform default. */
+    fun touchSlopPx(platformTouchSlopPx: Float, configuredTouchSlopPx: Int): Float =
+        configuredTouchSlopPx.takeIf { it > 0 }?.toFloat()
+            ?: platformTouchSlopPx.coerceAtLeast(0f)
+
+    /** Only discrete scroll-page commands honor this setting; direct dragging still flings. */
+    fun scrollPageAnimationSteps(noAnimation: Boolean): Int = if (noAnimation) 1 else 18
+}

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

@@ -0,0 +1,12 @@
+package io.legado.app.feature.reader.core.gesture
+
+import kotlin.math.abs
+
+/** Resolves a drag only after one axis strictly dominates the other. */
+object ReaderMainAxisPolicy {
+    fun isHorizontalDominant(deltaX: Float, deltaY: Float): Boolean =
+        abs(deltaX) > abs(deltaY)
+
+    fun isVerticalDominant(deltaX: Float, deltaY: Float): Boolean =
+        abs(deltaY) > abs(deltaX)
+}

+ 62 - 0
app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderPageViewportLayout.kt

@@ -0,0 +1,62 @@
+package io.legado.app.feature.reader.core.gesture
+
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderPageWindow
+import io.legado.app.feature.reader.core.model.ReaderRect
+import io.legado.app.feature.reader.core.selection.ReaderSelection
+import io.legado.app.feature.reader.core.selection.mergeSelectionBounds
+
+data class ReaderPagePlacement(
+    val page: ReaderPage,
+    val offsetY: Float,
+) {
+    fun containsViewportPoint(x: Float, y: Float): Boolean =
+        x in 0f..page.widthPx.toFloat() && y in offsetY..(offsetY + page.scrollExtentPx)
+
+    fun localY(viewportY: Float): Float = viewportY - offsetY
+}
+
+data class ReaderSelectionVisualBounds(
+    val page: ReaderPage,
+    val bounds: ReaderRect,
+)
+
+/** Single coordinate source for hit testing and selection across the three-page scroll stack. */
+class ReaderPageViewportLayout private constructor(
+    val placements: List<ReaderPagePlacement>,
+) {
+    fun pageAt(x: Float, y: Float): ReaderPagePlacement? =
+        placements.firstOrNull { it.containsViewportPoint(x, y) }
+
+    fun selectionBounds(selection: ReaderSelection): List<ReaderSelectionVisualBounds> =
+        placements.flatMap { placement ->
+            selection.bounds(placement.page).mergeSelectionBounds().map { bounds ->
+                ReaderSelectionVisualBounds(
+                    page = placement.page,
+                    bounds = bounds.offsetY(placement.offsetY),
+                )
+            }
+        }
+
+    companion object {
+        fun paged(window: ReaderPageWindow): ReaderPageViewportLayout =
+            ReaderPageViewportLayout(
+                listOfNotNull(window.current?.let { ReaderPagePlacement(it, 0f) }),
+            )
+
+        fun scroll(window: ReaderPageWindow, scrollOffsetPx: Float): ReaderPageViewportLayout {
+            val current = window.current ?: return ReaderPageViewportLayout(emptyList())
+            return ReaderPageViewportLayout(
+                listOfNotNull(
+                    window.previous?.let {
+                        ReaderPagePlacement(it, scrollOffsetPx - it.scrollExtentPx)
+                    },
+                    ReaderPagePlacement(current, scrollOffsetPx),
+                    window.next?.let {
+                        ReaderPagePlacement(it, scrollOffsetPx + current.scrollExtentPx)
+                    },
+                ),
+            )
+        }
+    }
+}

+ 79 - 0
app/src/main/java/io/legado/app/feature/reader/core/gesture/ReaderTapAction.kt

@@ -0,0 +1,79 @@
+package io.legado.app.feature.reader.core.gesture
+
+enum class ReaderTapAction(val legacyValue: Int) {
+    NONE(-1),
+    MENU(0),
+    NEXT_PAGE(1),
+    PREVIOUS_PAGE(2),
+    NEXT_CHAPTER(3),
+    PREVIOUS_CHAPTER(4),
+    READ_ALOUD_PREVIOUS_PARAGRAPH(5),
+    READ_ALOUD_NEXT_PARAGRAPH(6),
+    ADD_BOOKMARK(7),
+    OPEN_CONTENT_EDIT(8),
+    TOGGLE_REPLACE(9),
+    OPEN_CHAPTER_LIST(10),
+    OPEN_SEARCH(11),
+    SYNC_PROGRESS(12),
+    TOGGLE_READ_ALOUD_PAUSE(13);
+
+    companion object {
+        fun fromLegacyValue(value: Int): ReaderTapAction =
+            entries.firstOrNull { it.legacyValue == value } ?: NONE
+    }
+}
+
+data class ReaderTapActionGrid(
+    val topLeft: ReaderTapAction,
+    val topCenter: ReaderTapAction,
+    val topRight: ReaderTapAction,
+    val middleLeft: ReaderTapAction,
+    val middleCenter: ReaderTapAction,
+    val middleRight: ReaderTapAction,
+    val bottomLeft: ReaderTapAction,
+    val bottomCenter: ReaderTapAction,
+    val bottomRight: ReaderTapAction,
+) {
+    fun actionAt(x: Float, y: Float, width: Float, height: Float): ReaderTapAction {
+        if (width <= 0f || height <= 0f || x < 0f || y < 0f || x > width || y > height) return ReaderTapAction.NONE
+        val column = when {
+            x < width * .33f -> 0
+            x < width * .66f -> 1
+            else -> 2
+        }
+        val row = when {
+            y < height * .33f -> 0
+            y < height * .66f -> 1
+            else -> 2
+        }
+        return arrayOf(
+            topLeft, topCenter, topRight,
+            middleLeft, middleCenter, middleRight,
+            bottomLeft, bottomCenter, bottomRight,
+        )[row * 3 + column]
+    }
+
+    companion object {
+        fun fromLegacyValues(
+            topLeft: Int,
+            topCenter: Int,
+            topRight: Int,
+            middleLeft: Int,
+            middleCenter: Int,
+            middleRight: Int,
+            bottomLeft: Int,
+            bottomCenter: Int,
+            bottomRight: Int,
+        ) = ReaderTapActionGrid(
+            ReaderTapAction.fromLegacyValue(topLeft),
+            ReaderTapAction.fromLegacyValue(topCenter),
+            ReaderTapAction.fromLegacyValue(topRight),
+            ReaderTapAction.fromLegacyValue(middleLeft),
+            ReaderTapAction.fromLegacyValue(middleCenter),
+            ReaderTapAction.fromLegacyValue(middleRight),
+            ReaderTapAction.fromLegacyValue(bottomLeft),
+            ReaderTapAction.fromLegacyValue(bottomCenter),
+            ReaderTapAction.fromLegacyValue(bottomRight),
+        )
+    }
+}

+ 132 - 0
app/src/main/java/io/legado/app/feature/reader/core/layout/ChineseLineBreaker.kt

@@ -0,0 +1,132 @@
+package io.legado.app.feature.reader.core.layout
+
+/** Platform-free Chinese line breaking. Widths are supplied by a platform text measurer. */
+class ChineseLineBreaker(
+    private val clusters: List<String>,
+    private val widthsPx: List<Float>,
+    private val indentCharacters: Int,
+    widthPx: Int,
+    private val ideographWidthPx: Float,
+    letterSpacingPx: Float,
+    firstLineWidthPx: Int = widthPx,
+) {
+    private val starts = mutableListOf(0)
+    private val clusterStarts = mutableListOf(0)
+    private val widths = mutableListOf<Float>()
+    private val widthLimit = widthPx + letterSpacingPx
+    private val firstWidthLimit = firstLineWidthPx + letterSpacingPx
+
+    val lineStarts get() = starts.toIntArray()
+    val lineClusterStarts get() = clusterStarts.toIntArray()
+    val lineWidthsPx get() = widths.toFloatArray()
+    val lineCount get() = widths.size
+
+    init {
+        require(clusters.size == widthsPx.size)
+        breakLines()
+    }
+
+    private fun breakLines() {
+        if (clusters.isEmpty()) return
+        var lineWidth = 0f
+        var previousWidth = 0f
+        var textLength = 0
+        clusters.forEachIndexed { index, cluster ->
+            val currentWidth = widthsPx[index]
+            lineWidth += currentWidth
+            var carriedWidth = 0f
+            var carriedCharacters = 0
+            var carriedClusters = 0
+            val currentWidthLimit = if (widths.isEmpty()) firstWidthLimit else widthLimit
+            if (lineWidth > currentWidthLimit) {
+                var mode = when {
+                    index > 0 && clusters[index - 1] in opening -> Mode.PULL_PREVIOUS
+                    cluster in closing -> Mode.PULL_PREVIOUS
+                    else -> Mode.NORMAL
+                }
+                var rewindClusters = 0
+                var rewindCharacters = 0
+                // 本引擎不做行尾标点压缩(旧 ZhLayout 的 CPS_*),凡 PULL_PREVIOUS 会把
+                // 收尾标点留到下一行行首的,都必须回退到更早的非标点边界
+                val needsRecheck = mode == Mode.PULL_PREVIOUS && (
+                    (index > 0 && clusters[index - 1] in closing) ||
+                        (index < clusters.lastIndex && clusters[index + 1] in closing)
+                    )
+                if (needsRecheck && index > 2) {
+                    val lineStart = if (widths.isEmpty()) indentCharacters else clusterStarts.last()
+                    mode = Mode.NORMAL
+                    for (candidate in index downTo lineStart + 1) {
+                        if (candidate == index) {
+                            previousWidth = 0f
+                        } else {
+                            rewindClusters++
+                            rewindCharacters += clusters[candidate].length
+                            previousWidth += widthsPx[candidate]
+                        }
+                        if (clusters[candidate] !in closing && clusters[candidate - 1] !in opening) {
+                            mode = Mode.REWIND
+                            break
+                        }
+                    }
+                }
+                when (mode) {
+                    Mode.NORMAL -> {
+                        carriedWidth = currentWidth
+                        addStart(textLength, index)
+                        carriedCharacters = cluster.length
+                        carriedClusters = 1
+                    }
+                    Mode.PULL_PREVIOUS -> {
+                        carriedWidth = currentWidth + previousWidth
+                        addStart(textLength - clusters[index - 1].length, index - 1)
+                        carriedCharacters = clusters[index - 1].length + cluster.length
+                        carriedClusters = 2
+                    }
+                    Mode.REWIND -> {
+                        carriedWidth = currentWidth + previousWidth
+                        addStart(textLength - rewindCharacters, index - rewindClusters)
+                        carriedCharacters = rewindCharacters + cluster.length
+                        carriedClusters = rewindClusters + 1
+                    }
+                }
+                widths += lineWidth - carriedWidth
+                lineWidth = carriedWidth
+            }
+            if (index == clusters.lastIndex) {
+                if (starts.size == widths.size + 1) {
+                    starts += textLength + cluster.length
+                    clusterStarts += index + 1
+                    widths += lineWidth
+                } else if (carriedClusters > 0) {
+                    starts += starts.last() + carriedCharacters
+                    clusterStarts += clusterStarts.last() + carriedClusters
+                    widths += lineWidth
+                }
+            }
+            textLength += cluster.length
+            previousWidth = currentWidth
+        }
+    }
+
+    private fun addStart(character: Int, cluster: Int) {
+        starts += character
+        clusterStarts += cluster
+    }
+
+    private enum class Mode { NORMAL, PULL_PREVIOUS, REWIND }
+
+    companion object {
+        internal fun isForbiddenBreak(previous: String, next: String): Boolean =
+            next in closing || previous in opening
+
+        private val closing = setOf(
+            "!", ",", "。", "、", ";", ":", "?", "”", "’", ")", "]", "}", "》",
+            "〉", "〕", "】", "〗", "」", "』", "﹂", "﹄", "…", "—", "~", "·",
+            "!", ",", ".", ":", ";", "?", ")", "]", "}", ">",
+        )
+        private val opening = setOf(
+            "“", "‘", "(", "[", "{", "《", "〈", "〔", "【", "〖", "『", "「", "﹁", "﹃",
+            "(", "[", "{", "<",
+        )
+    }
+}

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

@@ -0,0 +1,414 @@
+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.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.style.ReaderCharacterStyle
+import io.legado.app.feature.reader.core.style.ReaderCharacterStyleResolver
+import io.legado.app.feature.reader.core.style.ReaderStyleRange
+import kotlin.coroutines.cancellation.CancellationException
+
+fun interface ReaderTextShaperFactory {
+    fun create(style: ReaderTextStyle): ReaderTextShaper
+}
+
+fun interface ReaderHtmlSourceResolver {
+    fun resolve(html: String, chapterPosition: Int): List<ReaderHtmlParagraph>?
+}
+
+data class ReaderHtmlParagraph(
+    val items: List<ReaderChapterInlineSource>,
+    val firstLineMarginPx: Float = 0f,
+    val restLineMarginPx: Float = 0f,
+    val alignment: ReaderTextAlignment? = null,
+    val decorations: List<ReaderParagraphDecoration> = emptyList(),
+)
+
+enum class ReaderParagraphDecorationKind { QUOTE, BULLET }
+
+data class ReaderParagraphDecoration(
+    val kind: ReaderParagraphDecorationKind,
+    val colorArgb: Int?,
+    val sizePx: Float,
+    val leadingOffsetPx: Float = 0f,
+)
+
+data class ReaderImageDimensions(val widthPx: Float, val heightPx: Float)
+
+enum class ReaderImageLayoutMode { AUTO, INLINE, STANDALONE, FULL_WIDTH, SINGLE_PAGE }
+
+data class ReaderImageOptions(
+    val layoutMode: ReaderImageLayoutMode? = null,
+    val requestedWidthPx: Float? = null,
+    val requestedWidthFraction: Float? = null,
+    val horizontalAlignment: ReaderTextAlignment? = null,
+    val action: String? = null,
+)
+
+fun interface ReaderImageDimensionsResolver {
+    suspend fun resolve(source: String): ReaderImageDimensions?
+}
+
+fun interface ReaderImageOptionsResolver {
+    fun resolve(source: String): ReaderImageOptions?
+}
+
+data class ReaderChapterMeasureStyle(
+    val bodyStyle: ReaderTextStyle,
+    val titleStyle: ReaderTextStyle,
+    val bodyIndentCharacters: Int,
+    val bodyAlignment: ReaderTextAlignment,
+    val titleAlignment: ReaderTextAlignment,
+    val imagePageBreakBefore: Boolean = false,
+    val imagePageBreakAfter: Boolean = false,
+    val bodyLineHeightPx: Float? = null,
+    val bodyBaselineOffsetPx: Float? = null,
+    val titleLineHeightPx: Float? = null,
+    val titleBaselineOffsetPx: Float? = null,
+    val standaloneImageThresholdPx: Float = 80f,
+    val styleRanges: List<ReaderStyleRange> = emptyList(),
+    val bodyLineSpacingMultiplier: Float = 1f,
+    val titleLineSpacingMultiplier: Float = 1f,
+    val letterSpacingEm: Float? = null,
+    val bodyIndentText: String? = null,
+    val imageLayoutMode: ReaderImageLayoutMode = ReaderImageLayoutMode.AUTO,
+    val imageAvailableWidthPx: Float? = null,
+)
+
+sealed interface ReaderChapterMeasureResult {
+    data class Success(val blocks: List<ReaderMeasuredBlock>) : ReaderChapterMeasureResult
+    data class Unsupported(val reason: String) : ReaderChapterMeasureResult
+}
+
+class ReaderChapterBlockMeasurer(
+    bodyShaper: ReaderTextShaper,
+    titleShaper: ReaderTextShaper,
+    private val imageDimensionsResolver: ReaderImageDimensionsResolver,
+    private val textShaperFactory: ReaderTextShaperFactory = ReaderTextShaperFactory { bodyShaper },
+    private val htmlSourceResolver: ReaderHtmlSourceResolver = ReaderHtmlSourceResolver { _, _ -> null },
+    private val imageOptionsResolver: ReaderImageOptionsResolver = ReaderImageOptionsResolver { null },
+) {
+    suspend fun measure(
+        source: ReaderChapterSource,
+        style: ReaderChapterMeasureStyle,
+    ): ReaderChapterMeasureResult {
+        val blocks = ArrayList<ReaderMeasuredBlock>(source.blocks.size)
+        val shapers = mutableMapOf<ReaderTextStyle, ReaderTextShaper>()
+        fun shaper(textStyle: ReaderTextStyle) = shapers.getOrPut(textStyle) {
+            textShaperFactory.create(textStyle)
+        }
+        val bodyIndentText = style.bodyIndentText ?: " ".repeat(style.bodyIndentCharacters.coerceAtLeast(0))
+        val bodyIndentWidth by lazy {
+            val shaped = shaper(style.bodyStyle).shape(bodyIndentText)
+            shaped.widthsPx.sum() + (style.letterSpacingEm ?: 0f) * style.bodyStyle.fontSizePx * shaped.text.size
+        }
+        suspend fun addStyledParagraph(
+            items: List<ReaderChapterInlineSource>,
+            isTitle: Boolean,
+            titleScale: Float = 1f,
+            isSubtitle: Boolean = false,
+            applyBodyIndent: Boolean = true,
+            firstLineMarginPx: Float = 0f,
+            restLineMarginPx: Float = 0f,
+            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
+            val subtitleBounds = if (isTitle && isSubtitle) shaper(baseStyle).fontBounds else null
+            val lineHeight = subtitleBounds?.heightPx
+                ?: if (isTitle) style.titleLineHeightPx?.times(titleScale) else style.bodyLineHeightPx
+            val baselineOffset = subtitleBounds?.baselineOffsetPx
+                ?: if (isTitle) style.titleBaselineOffsetPx?.times(titleScale) else style.bodyBaselineOffsetPx
+            val titleSpacingScale = if (subtitleBounds != null) {
+                subtitleBounds.heightPx / (style.titleLineHeightPx ?: style.titleStyle.fontSizePx).coerceAtLeast(1f)
+            } else titleScale
+            val blankLine = items.singleOrNull() as? ReaderChapterInlineSource.BlankLine
+            if (blankLine != null) {
+                blocks += ReaderMeasuredBlock.BlankLine(
+                    chapterPosition = blankLine.chapterPosition,
+                    lineHeightPx = lineHeight ?: baseStyle.fontSizePx,
+                    lineSpacingMultiplier = if (isTitle) {
+                        style.titleLineSpacingMultiplier
+                    } else style.bodyLineSpacingMultiplier,
+                )
+                return null
+            }
+            val firstText = items.firstOrNull() as? ReaderChapterInlineSource.Text
+            val prefixEnd = firstText?.takeIf {
+                applyBodyIndent && !isTitle && bodyIndentText.isNotEmpty() &&
+                    it.value.startsWith(bodyIndentText)
+            }?.let { it.chapterPosition + bodyIndentText.length }
+            var emittedContent = false
+            var hasStandaloneImage = false
+            val inline = mutableListOf<ReaderMeasuredInlineItem>()
+            fun flushInline(skipBlank: Boolean = false) {
+                if (inline.isEmpty()) return
+                // Processed paragraphs include indentation even when they contain only an image.
+                // Hide that geometry, but retain the source's character-position space.
+                if (skipBlank && inline.all { it is ReaderMeasuredInlineItem.Text && it.value.isBlank() }) {
+                    inline.clear()
+                    return
+                }
+                val leadingIndentItems = if (prefixEnd == null) 0 else inline.takeWhile {
+                    it is ReaderMeasuredInlineItem.Text && it.chapterPosition < prefixEnd
+                }.size
+                val needsIndent = applyBodyIndent && !isTitle &&
+                    leadingIndentItems == 0 && !emittedContent
+                val htmlFirstLineMargin = if (emittedContent) restLineMarginPx else firstLineMarginPx
+                blocks += ReaderMeasuredBlock.InlineParagraph(
+                    items = inline.toList(),
+                    indentCharacters = if (needsIndent) style.bodyIndentCharacters else 0,
+                    indentWidthPx = if (needsIndent) bodyIndentWidth else htmlFirstLineMargin,
+                    restLineIndentWidthPx = restLineMarginPx,
+                    leadingIndentItems = leadingIndentItems,
+                    decorations = decorations,
+                    justifyAtWordBoundaries = justifyAtWordBoundaries,
+                    alignment = alignmentOverride ?: if (isTitle) style.titleAlignment else style.bodyAlignment,
+                    lineHeightPx = lineHeight ?: baseStyle.fontSizePx,
+                    baselineOffsetPx = baselineOffset ?: baseStyle.fontSizePx,
+                    baseTextSizePx = baseStyle.fontSizePx,
+                    emphasized = isTitle,
+                    titleSpacingScale = if (isTitle) titleSpacingScale else 1f,
+                    lineSpacingMultiplier = if (isTitle) style.titleLineSpacingMultiplier else style.bodyLineSpacingMultiplier,
+                    letterSpacingPx = style.letterSpacingEm?.times(baseStyle.fontSizePx),
+                )
+                inline.clear()
+                emittedContent = true
+            }
+            items.forEach { item ->
+                when (item) {
+                    is ReaderChapterInlineSource.Text -> {
+                        val htmlStyle = baseStyle.merge(item.style)
+                        val initiallyShaped = ReaderParagraphFactory(shaper(htmlStyle))
+                            .create(item.value, htmlStyle, item.chapterPosition)
+                        var offset = 0
+                        initiallyShaped.clusters.forEach { cluster ->
+                            val position = item.chapterPosition + offset
+                            val rangeStyle = ReaderCharacterStyleResolver.resolve(style.styleRanges, position, isTitle)
+                            val textStyle = htmlStyle.merge(rangeStyle)
+                            val textShaper = shaper(textStyle)
+                            val width = textShaper.shape(cluster).widthsPx.firstOrNull() ?: 0f
+                            // The paragraph already owns the base line box (including the special
+                            // subtitle bounds). Style overrides and baseline-shift spans need
+                            // per-glyph metrics so their visual extents can expand the shared line.
+                            val hasBaselineShift = item.style.superscript || item.style.subscript
+                            val lineMetrics = textShaper.fontLineMetrics.takeIf {
+                                textStyle != baseStyle || hasBaselineShift
+                            }
+                            val baselineShift = lineMetrics?.let { metrics ->
+                                (if (item.style.superscript) -metrics.ascentPx / 2f else 0f) +
+                                    (if (item.style.subscript) metrics.descentPx / 2f else 0f)
+                            } ?: 0f
+                            inline += ReaderMeasuredInlineItem.Text(
+                                value = cluster,
+                                widthPx = width,
+                                style = textStyle,
+                                chapterPosition = position,
+                                link = item.style.link,
+                                markingId = rangeStyle?.markingId,
+                                lineHeightPx = lineMetrics?.heightPx,
+                                baselineOffsetPx = lineMetrics?.baselineOffsetPx,
+                                baselineShiftPx = baselineShift,
+                            )
+                            offset += cluster.length
+                        }
+                    }
+                    is ReaderChapterInlineSource.Image -> {
+                        // A broken image must not make the entire chapter disappear. The bitmap
+                        // loader already supplies an error image; reserve stable line geometry
+                        // until real dimensions are available.
+                        val placeholderExtent = (lineHeight ?: baseStyle.fontSizePx).coerceAtLeast(1f)
+                        val originalSize = imageDimensionsResolver.resolve(item.source)
+                            ?: ReaderImageDimensions(placeholderExtent, placeholderExtent)
+                        val options = imageOptionsResolver.resolve(item.source)
+                        val requestedWidth = options?.requestedWidthFraction?.let { fraction ->
+                            style.imageAvailableWidthPx?.times(fraction)
+                        } ?: options?.requestedWidthPx
+                        val size = originalSize.withWidth(requestedWidth)
+                        val mode = options?.layoutMode ?: if (
+                            style.imagePageBreakBefore && style.imagePageBreakAfter
+                        ) ReaderImageLayoutMode.SINGLE_PAGE else style.imageLayoutMode
+                        val standalone = mode != ReaderImageLayoutMode.INLINE && (
+                            mode == ReaderImageLayoutMode.STANDALONE ||
+                            mode == ReaderImageLayoutMode.FULL_WIDTH ||
+                            mode == ReaderImageLayoutMode.SINGLE_PAGE ||
+                            size.widthPx >= style.standaloneImageThresholdPx ||
+                            size.heightPx >= style.standaloneImageThresholdPx)
+                        if (standalone) {
+                            flushInline(skipBlank = true)
+                            hasStandaloneImage = true
+                            blocks += ReaderMeasuredBlock.Image(
+                                source = item.source,
+                                intrinsicWidthPx = size.widthPx,
+                                intrinsicHeightPx = size.heightPx,
+                                chapterPosition = item.chapterPosition,
+                                action = options?.action,
+                                horizontalAlignment = options?.horizontalAlignment ?: ReaderTextAlignment.CENTER,
+                                scaleMode = mode.toScaleMode(),
+                                pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
+                                pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
+                            )
+                        } else {
+                            val maxHeight = lineHeight ?: baseStyle.fontSizePx
+                            val scale = minOf(1f, maxHeight / size.heightPx.coerceAtLeast(1f))
+                            inline += ReaderMeasuredInlineItem.Image(
+                                source = item.source,
+                                widthPx = size.widthPx * scale,
+                                heightPx = size.heightPx * scale,
+                                chapterPosition = item.chapterPosition,
+                                action = options?.action,
+                            )
+                        }
+                    }
+                    is ReaderChapterInlineSource.BlankLine -> Unit
+                }
+            }
+            flushInline(skipBlank = hasStandaloneImage)
+            return null
+        }
+        source.blocks.forEach { block ->
+            when (block) {
+                is ReaderChapterSourceBlock.Text -> {
+                    addStyledParagraph(
+                        listOf(ReaderChapterInlineSource.Text(block.value, block.chapterPosition)),
+                        block.isTitle,
+                        block.fontSizeScale,
+                        block.isSubtitle,
+                    )?.let { return it }
+                }
+                is ReaderChapterSourceBlock.Image -> {
+                    val placeholderExtent = (style.bodyLineHeightPx ?: style.bodyStyle.fontSizePx)
+                        .coerceAtLeast(1f)
+                    val originalSize = imageDimensionsResolver.resolve(block.source)
+                        ?: ReaderImageDimensions(placeholderExtent, placeholderExtent)
+                    val options = imageOptionsResolver.resolve(block.source)
+                    val requestedWidth = options?.requestedWidthFraction?.let { fraction ->
+                        style.imageAvailableWidthPx?.times(fraction)
+                    } ?: options?.requestedWidthPx
+                    val size = originalSize.withWidth(requestedWidth)
+                    val mode = options?.layoutMode ?: if (
+                        style.imagePageBreakBefore && style.imagePageBreakAfter
+                    ) ReaderImageLayoutMode.SINGLE_PAGE else style.imageLayoutMode
+                    blocks += ReaderMeasuredBlock.Image(
+                        source = block.source,
+                        intrinsicWidthPx = size.widthPx,
+                        intrinsicHeightPx = size.heightPx,
+                        chapterPosition = block.chapterPosition,
+                        action = options?.action,
+                        horizontalAlignment = options?.horizontalAlignment ?: ReaderTextAlignment.CENTER,
+                        scaleMode = mode.toScaleMode(),
+                        pageBreakBefore = mode == ReaderImageLayoutMode.SINGLE_PAGE,
+                        pageBreakAfter = mode == ReaderImageLayoutMode.SINGLE_PAGE,
+                    )
+                }
+                is ReaderChapterSourceBlock.Paragraph -> {
+                    addStyledParagraph(block.items, false)?.let { return it }
+                }
+                is ReaderChapterSourceBlock.Html -> {
+                    val paragraphs = try {
+                        htmlSourceResolver.resolve(block.value, block.chapterPosition)
+                    } catch (error: CancellationException) {
+                        throw error
+                    } catch (_: Exception) {
+                        null
+                    }
+                        ?: fallbackHtmlParagraphs(source, block)
+                    paragraphs.forEach { paragraph ->
+                        addStyledParagraph(
+                            items = paragraph.items,
+                            isTitle = false,
+                            applyBodyIndent = false,
+                            firstLineMarginPx = paragraph.firstLineMarginPx,
+                            restLineMarginPx = paragraph.restLineMarginPx,
+                            alignmentOverride = paragraph.alignment,
+                            decorations = paragraph.decorations,
+                            justifyAtWordBoundaries = true,
+                        )?.let { return it }
+                    }
+                }
+                is ReaderChapterSourceBlock.PageBreak -> blocks += ReaderMeasuredBlock.PageBreak
+            }
+        }
+        return ReaderChapterMeasureResult.Success(blocks)
+    }
+}
+
+/**
+ * Preserves readable content and the parser-owned chapter-position space when styled HTML
+ * conversion is unavailable. Normal HTML conversion remains the preferred path.
+ */
+private fun fallbackHtmlParagraphs(
+    source: ReaderChapterSource,
+    block: ReaderChapterSourceBlock.Html,
+): List<ReaderHtmlParagraph> {
+    val start = block.chapterPosition.coerceIn(0, source.semanticContent.length)
+    val end = (start + block.semanticLength).coerceIn(start, source.semanticContent.length)
+    val semanticText = source.semanticContent.substring(start, end)
+    if (semanticText.isEmpty()) return emptyList()
+
+    val paragraphs = mutableListOf<ReaderHtmlParagraph>()
+    var paragraphStart = 0
+    semanticText.forEachIndexed { index, character ->
+        if (character != '\n') return@forEachIndexed
+        val value = semanticText.substring(paragraphStart, index)
+        val position = start + paragraphStart
+        paragraphs += ReaderHtmlParagraph(
+            if (value.isEmpty()) {
+                listOf(ReaderChapterInlineSource.BlankLine(position))
+            } else {
+                listOf(ReaderChapterInlineSource.Text(value, position))
+            },
+        )
+        paragraphStart = index + 1
+    }
+    if (paragraphStart < semanticText.length) {
+        paragraphs += ReaderHtmlParagraph(
+            listOf(ReaderChapterInlineSource.Text(
+                semanticText.substring(paragraphStart),
+                start + paragraphStart,
+            )),
+        )
+    }
+    return paragraphs
+}
+
+private fun ReaderImageDimensions.withWidth(requestedWidthPx: Float?): ReaderImageDimensions {
+    val width = requestedWidthPx?.takeIf { it > 0f && widthPx > 0f } ?: return this
+    return ReaderImageDimensions(width, heightPx * width / widthPx)
+}
+
+private fun ReaderImageLayoutMode.toScaleMode(): ReaderImageScaleMode = when (this) {
+    ReaderImageLayoutMode.FULL_WIDTH -> ReaderImageScaleMode.FIT_WIDTH
+    ReaderImageLayoutMode.SINGLE_PAGE -> ReaderImageScaleMode.FIT_PAGE
+    else -> ReaderImageScaleMode.CONTAIN_NO_UPSCALE
+}
+
+private fun ReaderTextStyle.merge(override: ReaderInlineSourceStyle): ReaderTextStyle = copy(
+    colorArgb = override.colorArgb ?: colorArgb,
+    backgroundArgb = override.backgroundArgb ?: backgroundArgb,
+    fontWeight = override.fontWeight ?: fontWeight,
+    italic = override.italic ?: italic,
+    fontSizePx = fontSizePx * override.fontSizeScale,
+    fontFamily = override.fontFamily ?: fontFamily,
+    strikeThrough = override.strikeThrough || strikeThrough,
+    nativeUnderline = override.underline || nativeUnderline,
+)
+
+private fun ReaderTextStyle.merge(override: ReaderCharacterStyle?): ReaderTextStyle {
+    if (override == null) return this
+    return copy(
+        colorArgb = override.colorArgb ?: colorArgb,
+        backgroundArgb = override.backgroundArgb ?: backgroundArgb,
+        underline = override.underline ?: underline,
+        fontPath = override.fontPath ?: fontPath,
+        fontWeight = override.fontWeight ?: fontWeight,
+        italic = override.italic ?: italic,
+        fontSizePx = (fontSizePx + override.fontSizeOffsetPx).coerceAtLeast(1f),
+        backgroundImage = override.backgroundImage ?: backgroundImage,
+    )
+}

+ 24 - 0
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderColumnMode.kt

@@ -0,0 +1,24 @@
+package io.legado.app.feature.reader.core.layout
+
+enum class ReaderColumnMode {
+    SINGLE, DOUBLE, LANDSCAPE, LANDSCAPE_OR_TABLET;
+
+    fun columnCount(widthPx: Int, heightPx: Int, isTablet: Boolean, isScroll: Boolean): Int {
+        val double = when (this) {
+            SINGLE -> false
+            DOUBLE -> true // Explicit double-page mode also applies to scroll, as in the View reader.
+            LANDSCAPE -> widthPx > heightPx && !isScroll
+            LANDSCAPE_OR_TABLET -> (widthPx > heightPx || isTablet) && !isScroll
+        }
+        return if (double) 2 else 1
+    }
+
+    companion object {
+        fun fromPreference(value: String): ReaderColumnMode = when (value) {
+            "1" -> DOUBLE
+            "2" -> LANDSCAPE
+            "3" -> LANDSCAPE_OR_TABLET
+            else -> SINGLE
+        }
+    }
+}

+ 42 - 0
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderLayoutMath.kt

@@ -0,0 +1,42 @@
+package io.legado.app.feature.reader.core.layout
+
+data class GlyphClusters(val text: List<String>, val widthsPx: List<Float>)
+
+fun clusterGlyphs(text: String, measuredWidthsPx: FloatArray, start: Int = 0): GlyphClusters {
+    require(start >= 0 && start + text.length <= measuredWidthsPx.size)
+    val clusters = ArrayList<String>()
+    val widths = ArrayList<Float>()
+    var index = 0
+    while (index < text.length) {
+        val clusterStart = index++
+        widths += measuredWidthsPx[start + clusterStart]
+        while (index < text.length && measuredWidthsPx[start + index] == 0f &&
+            text[index].code !in setOf(0x200B, 0x200C, 0x200D, 0x2060)
+        ) index++
+        clusters += text.substring(clusterStart, index)
+    }
+    return GlyphClusters(clusters, widths)
+}
+
+data class ReaderContentBounds(
+    val widthPx: Int,
+    val heightPx: Int,
+    val rightPx: Int,
+    val bottomPx: Int,
+)
+
+fun calculateContentBounds(
+    viewportWidthPx: Int,
+    viewportHeightPx: Int,
+    paddingLeftPx: Int,
+    paddingTopPx: Int,
+    paddingRightPx: Int,
+    paddingBottomPx: Int,
+    columns: Int = 1,
+): ReaderContentBounds {
+    require(columns > 0)
+    val columnWidth = viewportWidthPx / columns
+    val width = (columnWidth - paddingLeftPx - paddingRightPx).coerceAtLeast(0)
+    val height = (viewportHeightPx - paddingTopPx - paddingBottomPx).coerceAtLeast(0)
+    return ReaderContentBounds(width, height, columnWidth - paddingRightPx, paddingTopPx + height)
+}

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

@@ -0,0 +1,675 @@
+package io.legado.app.feature.reader.core.layout
+
+import io.legado.app.feature.reader.core.model.ReaderElement
+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.ReaderPageId
+import io.legado.app.feature.reader.core.model.ReaderRect
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import io.legado.app.feature.reader.core.model.ReaderEmphasisUnderline
+import kotlin.math.max
+
+enum class ReaderTextAlignment { START, CENTER, END, JUSTIFY }
+enum class ReaderImageScaleMode { CONTAIN_NO_UPSCALE, FIT_WIDTH, FIT_PAGE }
+
+data class ReaderPageUnderline(
+    val colorArgb: Int,
+    val widthPx: Float,
+    val offsetPx: Float,
+    val extendToColumn: Boolean,
+    val dashed: Boolean,
+    val dashOnPx: Float = 6f,
+    val dashOffPx: Float = 6f,
+)
+
+data class ReaderPaginationConfig(
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val viewportWidthPx: Int,
+    val viewportHeightPx: Int,
+    val paddingLeftPx: Float,
+    val paddingTopPx: Float,
+    val paddingRightPx: Float,
+    val paddingBottomPx: Float,
+    val lineHeightPx: Float,
+    val baselineOffsetPx: Float,
+    val paragraphSpacingPx: Float = 0f,
+    val letterSpacingPx: Float = 0f,
+    val revision: Long = 0L,
+    val decoration: ReaderPageDecoration = ReaderPageDecoration(),
+    val titleTopSpacingPx: Float = 0f,
+    val titleBottomSpacingPx: Float = 0f,
+    val titleParagraphSpacingPx: Float? = null,
+    val titleSegmentSpacingPx: Float = 0f,
+    val columnCount: Int = 1,
+    val lineSpacingMultiplier: Float = 1f,
+    val continuousScroll: Boolean = false,
+    val textBottomJustify: Boolean = false,
+    val pageUnderline: ReaderPageUnderline? = null,
+    val inlineImagesPreserveScrollLine: Boolean = true,
+    val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
+) {
+    init {
+        require(columnCount in 1..2)
+    }
+    // Both columns have their own left/right padding, matching the View reader's gutter.
+    val columnStridePx get() = viewportWidthPx / columnCount
+    val contentWidthPx get() = (columnStridePx - paddingLeftPx - paddingRightPx).coerceAtLeast(0f)
+    val contentBottomPx get() = (viewportHeightPx - paddingBottomPx).coerceAtLeast(paddingTopPx)
+}
+
+private data class ReaderLayoutRow(
+    val elementStart: Int,
+    val elementEnd: Int,
+    val top: Float,
+    val bottom: Float,
+    val standaloneImage: Boolean = false,
+)
+
+/** A shaped paragraph. Android supplies glyph-cluster widths using the same font used by Canvas. */
+data class ReaderMeasuredParagraph(
+    val text: String,
+    val clusters: List<String>,
+    val clusterWidthsPx: List<Float>,
+    val style: ReaderTextStyle,
+    val chapterPosition: Int,
+    val indentCharacters: Int = 0,
+    val alignment: ReaderTextAlignment = ReaderTextAlignment.START,
+    val isTitle: Boolean = false,
+    val link: String? = null,
+    val lineHeightPx: Float? = null,
+    val baselineOffsetPx: Float? = null,
+    val lineSpacingMultiplier: Float? = null,
+    val letterSpacingPx: Float? = null,
+    val indentWidthPx: Float? = null,
+) {
+    init {
+        require(clusters.size == clusterWidthsPx.size)
+        require(clusters.joinToString("") == text)
+    }
+}
+
+sealed interface ReaderMeasuredBlock {
+    data class Paragraph(val value: ReaderMeasuredParagraph) : ReaderMeasuredBlock
+
+    data class Image(
+        val source: String,
+        val intrinsicWidthPx: Float,
+        val intrinsicHeightPx: Float,
+        val chapterPosition: Int,
+        val action: String? = null,
+        val horizontalAlignment: ReaderTextAlignment = ReaderTextAlignment.CENTER,
+        val scaleMode: ReaderImageScaleMode = ReaderImageScaleMode.CONTAIN_NO_UPSCALE,
+        val pageBreakBefore: Boolean = false,
+        val pageBreakAfter: Boolean = false,
+    ) : ReaderMeasuredBlock
+
+    data class InlineParagraph(
+        val items: List<ReaderMeasuredInlineItem>,
+        val indentCharacters: Int,
+        val alignment: ReaderTextAlignment,
+        val lineHeightPx: Float,
+        val baselineOffsetPx: Float,
+        val baseTextSizePx: Float,
+        val emphasized: Boolean = false,
+        val titleSpacingScale: Float = 1f,
+        val lineSpacingMultiplier: Float = 1f,
+        val letterSpacingPx: Float? = null,
+        val indentWidthPx: Float? = null,
+        val restLineIndentWidthPx: Float = 0f,
+        val leadingIndentItems: Int = 0,
+        val decorations: List<ReaderParagraphDecoration> = emptyList(),
+        /** Legacy HTML justification expands word spaces when a line contains several of them. */
+        val justifyAtWordBoundaries: Boolean = false,
+    ) : ReaderMeasuredBlock
+
+    data class BlankLine(
+        val chapterPosition: Int,
+        val lineHeightPx: Float,
+        val lineSpacingMultiplier: Float,
+    ) : ReaderMeasuredBlock
+
+    data class Rule(
+        val colorArgb: Int,
+        val widthPx: Float,
+        val verticalPaddingPx: Float,
+        val dashed: Boolean = false,
+    ) : ReaderMeasuredBlock
+
+    data object PageBreak : ReaderMeasuredBlock
+}
+
+sealed interface ReaderMeasuredInlineItem {
+    val widthPx: Float
+    val chapterPosition: Int
+
+    data class Text(
+        val value: String,
+        override val widthPx: Float,
+        val style: ReaderTextStyle,
+        override val chapterPosition: Int,
+        val link: String? = null,
+        val markingId: String? = null,
+        val lineHeightPx: Float? = null,
+        val baselineOffsetPx: Float? = null,
+        /** Positive values move this glyph below the line baseline; negative values move it above. */
+        val baselineShiftPx: Float = 0f,
+    ) : ReaderMeasuredInlineItem
+
+    data class Image(
+        val source: String,
+        override val widthPx: Float,
+        val heightPx: Float,
+        override val chapterPosition: Int,
+        val action: String? = null,
+    ) : ReaderMeasuredInlineItem
+}
+
+/** Platform-free page assembler. It never creates or mutates legacy TextPage/TextLine objects. */
+object ReaderPaginator {
+    fun paginate(
+        paragraphs: List<ReaderMeasuredParagraph>,
+        config: ReaderPaginationConfig,
+    ): List<ReaderPage> = paginateBlocks(paragraphs.map { ReaderMeasuredBlock.Paragraph(it) }, config)
+
+    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>()
+
+        fun columnLeft() = config.paddingLeftPx + columnIndex * config.columnStridePx
+        fun columnHasContent() = elements.size > columnElementStart
+
+        fun addPageUnderline(rowElementStart: Int, lineBottom: Float) {
+            val underline = config.pageUnderline ?: return
+            if (elements.size <= rowElementStart) return
+            val rowElements = elements.subList(rowElementStart, 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,
+            )
+        }
+
+        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() {
+            if (!config.textBottomJustify || columnRows.size <= 1) return
+            val last = columnRows.last()
+            if (last.standaloneImage) return
+            val lastHeight = last.bottom - last.top
+            val reservedLineSpacing = config.lineHeightPx * config.lineSpacingMultiplier
+            if (config.contentBottomPx - (last.bottom + reservedLineSpacing) >= lastHeight) return
+            val surplus = config.contentBottomPx - last.bottom
+            if (surplus <= 0f) return
+            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)
+                }
+            }
+        }
+
+        fun finishPage() {
+            justifyColumnBottom()
+            if (elements.isNotEmpty()) {
+                pages += elements
+                pageTexts += pageText
+                pageExtents += if (config.continuousScroll) {
+                    max(config.contentBottomPx - config.paddingTopPx, y - config.paddingTopPx)
+                } else config.viewportHeightPx.toFloat()
+                elements = mutableListOf()
+                pageText = StringBuilder()
+            }
+            y = config.paddingTopPx
+            columnIndex = 0
+            columnElementStart = 0
+            columnRows = mutableListOf()
+        }
+
+        fun advanceColumn() {
+            if (!columnHasContent()) return
+            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,
+                    )
+                    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)
+            }
+            if (appendSeparator) {
+                pageText.append('\n')
+                y += if (paragraph.isTitle) config.titleParagraphSpacingPx ?: config.paragraphSpacingPx
+                    else config.paragraphSpacingPx
+            }
+        }
+
+        fun addImage(image: ReaderMeasuredBlock.Image) {
+            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 &&
+                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,
+            )
+            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"
+                }
+            }
+            fun backgroundImage(index: Int) = (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
+                ?.style?.backgroundImage?.takeIf { it.fit == 3 }
+            fun backgroundInsetBefore(index: Int, lineStart: Int): Float {
+                val image = backgroundImage(index) ?: return 0f
+                return if (index == lineStart || backgroundImage(index - 1) != image) image.contentInsetLeftPx else 0f
+            }
+            fun backgroundInsetAfter(index: Int, lineEnd: Int): Float {
+                val image = backgroundImage(index) ?: return 0f
+                return if (index + 1 == lineEnd || backgroundImage(index + 1) != 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)
+            // ZhLayout reserves the nine-slice side pieces after shaping, then pulls an
+            // overflowing line back into the column. Move whole clusters instead so Canvas
+            // keeps stable hit-test and selection geometry.
+            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
+                var until = originalEnds.firstOrNull { it > from } ?: paragraph.items.size
+                fun occupiedWidth(until: Int): Float = (from until until).sumOf { index ->
+                    (paragraph.items[index].widthPx + backgroundInsetBefore(index, from) +
+                        backgroundInsetAfter(index, until)).toDouble()
+                }.toFloat() + letterSpacing * (until - 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
+            }
+            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>()
+                val maxTextScale = 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)
+                fun backgroundInsetBefore(index: Int) = backgroundInsetBefore(from + index, from)
+                fun backgroundInsetAfter(index: Int) = backgroundInsetAfter(from + index, until)
+                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(
+                            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,
+                                    y + actualLineHeight / 2f,
+                                    columnLeft() + decoration.leadingOffsetPx + decoration.sizePx,
+                                    y + actualLineHeight / 2f,
+                                ),
+                                colorArgb = decoration.colorArgb ?: markerColor,
+                                strokeWidthPx = decoration.sizePx * 2f,
+                                circular = true,
+                            )
+                        }
+                    }
+                }
+                lineItems.forEachIndexed { itemIndex, item ->
+                    x += backgroundInsetBefore(itemIndex)
+                    when (item) {
+                        is ReaderMeasuredInlineItem.Text -> {
+                            val expandedWordSpace = if (item.value == " ") wordSpaceExtra else 0f
+                            elements += ReaderElement.Text(
+                                bounds = ReaderRect(
+                                    x, y, x + item.widthPx + expandedWordSpace, y + actualLineHeight,
+                                ),
+                                baselinePx = y + lineBaselineOffset + item.baselineShiftPx,
+                                value = item.value,
+                                style = item.style,
+                                selected = false,
+                                emphasized = paragraph.emphasized,
+                                link = item.link,
+                                markingId = item.markingId,
+                                chapterPosition = item.chapterPosition,
+                                paragraphIndex = paragraphIndex,
+                                backgroundFrameTopPx = item.style.backgroundImage?.takeIf { it.fit == 3 }?.let { image ->
+                                    val halfGap = ((paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * actualLineHeight) / 2f
+                                    val scale = (halfGap / maxOf(image.contentInsetTopPx, image.contentInsetBottomPx)
+                                        .coerceAtLeast(0.1f)).coerceIn(0f, 1f)
+                                    image.contentInsetTopPx * scale
+                                } ?: 0f,
+                                backgroundFrameBottomPx = item.style.backgroundImage?.takeIf { it.fit == 3 }?.let { image ->
+                                    val halfGap = ((paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * actualLineHeight) / 2f
+                                    val scale = (halfGap / maxOf(image.contentInsetTopPx, image.contentInsetBottomPx)
+                                        .coerceAtLeast(0.1f)).coerceIn(0f, 1f)
+                                    image.contentInsetBottomPx * scale
+                                } ?: 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(rowElementStart, y + actualLineHeight)
+                columnRows += ReaderLayoutRow(rowElementStart, elements.size, y, y + actualLineHeight)
+                y += actualLineHeight * paragraph.lineSpacingMultiplier
+            }
+            if (appendSeparator) {
+                pageText.append('\n')
+                y += if (paragraph.emphasized) (config.titleParagraphSpacingPx ?: config.paragraphSpacingPx) * paragraph.titleSpacingScale
+                    else config.paragraphSpacingPx
+            }
+        }
+
+        fun addRule(rule: ReaderMeasuredBlock.Rule) {
+            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 addBlankLine(
+            blank: ReaderMeasuredBlock.BlankLine,
+            paragraphIndex: Int,
+            appendSeparator: Boolean,
+        ) {
+            val height = blank.lineHeightPx
+            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
+            }
+        }
+
+        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 -> advanceColumn()
+            }
+            if (isTitle) {
+                y += if (blocks.getOrNull(index + 1)?.isTitle() == true) {
+                    config.titleSegmentSpacingPx * ((block as? ReaderMeasuredBlock.InlineParagraph)?.titleSpacingScale ?: 1f)
+                } else config.titleBottomSpacingPx
+            }
+        }
+        finishPage()
+        return pages.mapIndexed { pageIndex, pageElements ->
+            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 = pageExtents[pageIndex],
+                decoration = config.decoration,
+                inlineImagesPreserveScrollLine = config.inlineImagesPreserveScrollLine,
+                emphasisUnderlineStyle = config.emphasisUnderlineStyle,
+            )
+        }
+    }
+}

+ 51 - 0
app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderTextShaper.kt

@@ -0,0 +1,51 @@
+package io.legado.app.feature.reader.core.layout
+
+/** Actual font bounds at the shaped size, without leaking Android Paint into pagination. */
+data class ReaderFontBounds(val topPx: Float, val bottomPx: Float, val descentPx: Float) {
+    val heightPx: Float get() = bottomPx - topPx
+    val baselineOffsetPx: Float get() = heightPx - descentPx
+}
+
+/** Line box used by Android Layout: ascent/descent/leading rather than glyph-extreme bounds. */
+data class ReaderFontLineMetrics(
+    val heightPx: Float,
+    val baselineOffsetPx: Float,
+    /** Positive distances from the baseline, excluding leading. */
+    val ascentPx: Float = baselineOffsetPx,
+    val descentPx: Float = heightPx - baselineOffsetPx,
+)
+
+fun interface ReaderTextShaper {
+    fun shape(text: String): GlyphClusters
+    val fontBounds: ReaderFontBounds? get() = null
+    val fontLineMetrics: ReaderFontLineMetrics? get() = null
+}
+
+class ReaderParagraphFactory(private val shaper: ReaderTextShaper) {
+    fun create(
+        text: String,
+        style: io.legado.app.feature.reader.core.model.ReaderTextStyle,
+        chapterPosition: Int,
+        indentCharacters: Int = 0,
+        alignment: ReaderTextAlignment = ReaderTextAlignment.START,
+        isTitle: Boolean = false,
+        link: String? = null,
+        lineHeightPx: Float? = null,
+        baselineOffsetPx: Float? = null,
+    ): ReaderMeasuredParagraph {
+        val shaped = shaper.shape(text)
+        return ReaderMeasuredParagraph(
+            text = text,
+            clusters = shaped.text,
+            clusterWidthsPx = shaped.widthsPx,
+            style = style,
+            chapterPosition = chapterPosition,
+            indentCharacters = indentCharacters,
+            alignment = alignment,
+            isTitle = isTitle,
+            link = link,
+            lineHeightPx = lineHeightPx,
+            baselineOffsetPx = baselineOffsetPx,
+        )
+    }
+}

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

@@ -0,0 +1,4 @@
+package io.legado.app.feature.reader.core.model
+
+/** Stored reader background opacity uses the legacy 0..100 percentage scale. */
+fun readerBackgroundAlpha(percent: Float): Float = (percent / 100f).coerceIn(0f, 1f)

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

@@ -0,0 +1,39 @@
+package io.legado.app.feature.reader.core.model
+
+import androidx.compose.runtime.Stable
+
+/** Per-page decoration, so page transforms and curl clipping also apply to the bookmark. */
+@Stable
+data class ReaderBookmarkBadge(
+    val leftPx: Float,
+    val topPx: Float,
+    val widthPx: Int,
+    val heightPx: Int,
+    val imageSource: String = "",
+    val imageVersion: String = "",
+) {
+    companion object {
+        fun create(
+            hasBookmark: Boolean,
+            isScroll: Boolean,
+            pageWidthPx: Int,
+            contentTopPx: Float,
+            contentRightPaddingPx: Int,
+            density: Float,
+            sizeDp: Int,
+            imageSource: String = "",
+            imageVersion: String = "",
+        ): ReaderBookmarkBadge? {
+            if (!hasBookmark || isScroll) return null
+            val width = (sizeDp.coerceAtLeast(1) * density).toInt().coerceAtLeast(1)
+            return ReaderBookmarkBadge(
+                leftPx = pageWidthPx - contentRightPaddingPx - 6 * density - width,
+                topPx = contentTopPx + 2 * density,
+                widthPx = width,
+                heightPx = width * 2,
+                imageSource = imageSource,
+                imageVersion = imageVersion,
+            )
+        }
+    }
+}

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

@@ -0,0 +1,30 @@
+package io.legado.app.feature.reader.core.model
+
+data class ReaderEmphasisUnderline(
+    val colorArgb: Int,
+    val widthPx: Float,
+    val bottomOffsetPx: Float,
+)
+
+data class ReaderEmphasisUnderlineRun(
+    val startPx: Float,
+    val endPx: Float,
+    val yPx: Float,
+    val style: ReaderEmphasisUnderline,
+)
+
+/** Restores the legacy whole-line underline used for search hits and read-aloud paragraphs. */
+fun ReaderPage.emphasisUnderlineRuns(): List<ReaderEmphasisUnderlineRun> {
+    val lines = elements.filterIsInstance<ReaderElement.Text>()
+        .groupBy { it.bounds.top to it.bounds.bottom }
+    return lines.values.mapNotNull { line ->
+        val style = line.firstNotNullOfOrNull(ReaderElement.Text::emphasisUnderline)
+            ?: return@mapNotNull null
+        ReaderEmphasisUnderlineRun(
+            startPx = line.minOf { it.bounds.left },
+            endPx = line.maxOf { it.bounds.right },
+            yPx = line.maxOf { it.bounds.bottom } - style.bottomOffsetPx,
+            style = style,
+        )
+    }
+}

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

@@ -0,0 +1,10 @@
+package io.legado.app.feature.reader.core.model
+
+object ReaderImageCachePolicy {
+    fun key(element: ReaderElement.Image): String =
+        "${element.source}|${element.bounds.width.toInt()}x${element.bounds.height.toInt()}"
+
+    fun belongsToSource(key: String, source: String): Boolean = key.startsWith("$source|")
+
+    fun withGeneration(key: String, generation: Long): String = "$key|generation=$generation"
+}

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

@@ -0,0 +1,34 @@
+package io.legado.app.feature.reader.core.model
+
+/** Aspect-preserving destination geometry for reader images and error placeholders. */
+data class ReaderImageDrawLayout(
+    val leftPx: Float,
+    val topPx: Float,
+    val widthPx: Float,
+    val heightPx: Float,
+) {
+    companion object {
+        fun fitCenter(
+            container: ReaderRect,
+            imageWidthPx: Int,
+            imageHeightPx: Int,
+        ): ReaderImageDrawLayout? {
+            if (
+                container.width <= 0f || container.height <= 0f ||
+                imageWidthPx <= 0 || imageHeightPx <= 0
+            ) return null
+            val scale = minOf(
+                container.width / imageWidthPx,
+                container.height / imageHeightPx,
+            )
+            val width = imageWidthPx * scale
+            val height = imageHeightPx * scale
+            return ReaderImageDrawLayout(
+                leftPx = container.left + (container.width - width) / 2f,
+                topPx = container.top + (container.height - height) / 2f,
+                widthPx = width,
+                heightPx = height,
+            )
+        }
+    }
+}

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

@@ -0,0 +1,46 @@
+package io.legado.app.feature.reader.core.model
+
+data class ReaderIntRect(val left: Int, val top: Int, val right: Int, val bottom: Int)
+
+data class ReaderNineSliceCell(
+    val source: ReaderIntRect,
+    val destination: ReaderRect,
+)
+
+object ReaderNineSliceLayout {
+    fun cells(
+        bitmapWidth: Int,
+        bitmapHeight: Int,
+        content: ReaderRect,
+        frame: ReaderRect,
+        image: ReaderTextBackgroundImage,
+    ): List<ReaderNineSliceCell> {
+        if (bitmapWidth <= 0 || bitmapHeight <= 0) return emptyList()
+        val sx = intArrayOf(
+            0,
+            (bitmapWidth * image.ninePatchLeft.coerceIn(0f, 1f)).toInt(),
+            (bitmapWidth * (1f - image.ninePatchRight.coerceIn(0f, 1f))).toInt(),
+            bitmapWidth,
+        )
+        val sy = intArrayOf(
+            0,
+            (bitmapHeight * image.ninePatchTop.coerceIn(0f, 1f)).toInt(),
+            (bitmapHeight * (1f - image.ninePatchBottom.coerceIn(0f, 1f))).toInt(),
+            bitmapHeight,
+        )
+        if (sx[1] > sx[2] || sy[1] > sy[2]) return emptyList()
+        val dx = floatArrayOf(frame.left, content.left, content.right, frame.right)
+        val dy = floatArrayOf(frame.top, content.top, content.bottom, frame.bottom)
+        return buildList(9) {
+            for (row in 0..2) for (column in 0..2) {
+                if (sx[column] == sx[column + 1] || sy[row] == sy[row + 1]) continue
+                val destination = ReaderRect(dx[column], dy[row], dx[column + 1], dy[row + 1])
+                if (destination.width <= 0f || destination.height <= 0f) continue
+                add(ReaderNineSliceCell(
+                    ReaderIntRect(sx[column], sy[row], sx[column + 1], sy[row + 1]),
+                    destination,
+                ))
+            }
+        }
+    }
+}

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

@@ -0,0 +1,215 @@
+package io.legado.app.feature.reader.core.model
+
+data class ReaderPageId(val chapterIndex: Int, val pageIndex: Int)
+
+data class ReaderRect(val left: Float, val top: Float, val right: Float, val bottom: Float) {
+    val width get() = right - left
+    val height get() = bottom - top
+    fun contains(x: Float, y: Float): Boolean = x in left..right && y in top..bottom
+    fun offsetY(deltaY: Float) = copy(top = top + deltaY, bottom = bottom + deltaY)
+}
+
+data class ReaderTextStyle(
+    val colorArgb: Int,
+    val fontSizePx: Float,
+    val fontPath: String = "",
+    val fontWeight: Int = 400,
+    val italic: Boolean = false,
+    val backgroundArgb: Int? = null,
+    val underline: ReaderUnderline? = null,
+    val shadow: ReaderTextShadow? = null,
+    val backgroundImage: ReaderTextBackgroundImage? = null,
+    val fontFamily: String = "sans-serif",
+    val linearText: Boolean = false,
+    val strikeThrough: Boolean = false,
+    /** Font-native underline used by HTML UnderlineSpan; custom reader underlines stay separate. */
+    val nativeUnderline: Boolean = false,
+)
+
+data class ReaderTextBackgroundImage(
+    val source: String,
+    val fit: Int,
+    val scale: Float,
+    val ninePatchLeft: Float = 0.1f,
+    val ninePatchRight: Float = 0.1f,
+    val ninePatchTop: Float = 0.1f,
+    val ninePatchBottom: Float = 0.1f,
+    val contentInsetLeftPx: Float = 0f,
+    val contentInsetRightPx: Float = 0f,
+    val contentInsetTopPx: Float = 0f,
+    val contentInsetBottomPx: Float = 0f,
+)
+
+fun ReaderTextBackgroundImage.withBitmapWidth(widthPx: Int): ReaderTextBackgroundImage {
+    return withBitmapSize(widthPx, 0)
+}
+
+fun ReaderTextBackgroundImage.withBitmapSize(widthPx: Int, heightPx: Int): ReaderTextBackgroundImage {
+    if (fit != 3 || widthPx <= 0) return this
+    return copy(
+        contentInsetLeftPx = (widthPx * ninePatchLeft.coerceIn(0f, 1f)).toInt().toFloat(),
+        contentInsetRightPx = (widthPx * ninePatchRight.coerceIn(0f, 1f)).toInt().toFloat(),
+        contentInsetTopPx = (heightPx.coerceAtLeast(0) * ninePatchTop.coerceIn(0f, 1f)).toInt().toFloat(),
+        contentInsetBottomPx = (heightPx.coerceAtLeast(0) * ninePatchBottom.coerceIn(0f, 1f)).toInt().toFloat(),
+    )
+}
+
+data class ReaderTextShadow(
+    val colorArgb: Int,
+    val radiusPx: Float,
+    val dxPx: Float,
+    val dyPx: Float,
+)
+
+data class ReaderUnderline(
+    val mode: Int,
+    val colorArgb: Int,
+    val widthPx: Float,
+    val offsetPx: Float,
+    val svgPath: String = "",
+    val dashOnPx: Float = 8f,
+    val dashOffPx: Float = 5f,
+    val waveAmplitudePx: Float = 3f,
+    val waveLengthPx: Float = 12f,
+    val doubleLineGapPx: Float = 3f,
+)
+
+sealed interface ReaderElement {
+    val bounds: ReaderRect
+
+    data class Text(
+        override val bounds: ReaderRect,
+        val baselinePx: Float,
+        val value: String,
+        val style: ReaderTextStyle,
+        val selected: Boolean,
+        val emphasized: Boolean,
+        val readAloud: Boolean = false,
+        val searchResult: Boolean = false,
+        val emphasisUnderline: ReaderEmphasisUnderline? = null,
+        val link: String? = null,
+        val markingId: String? = null,
+        val chapterPosition: Int,
+        val paragraphIndex: Int = -1,
+        val backgroundFrameTopPx: Float = 0f,
+        val backgroundFrameBottomPx: Float = 0f,
+    ) : ReaderElement {
+        /** HTML links keep the legacy reader's accent priority, including during read-aloud. */
+        fun resolvedColorArgb(accentColorArgb: Int): Int =
+            if (link != null || readAloud || searchResult) accentColorArgb else style.colorArgb
+
+        val drawsLinkUnderline: Boolean
+            get() = link != null
+    }
+
+    data class Image(
+        override val bounds: ReaderRect,
+        val source: String,
+        val action: String?,
+        val chapterPosition: Int = 0,
+    ) : ReaderElement
+
+    data class Review(
+        override val bounds: ReaderRect,
+        val count: Int,
+        val paragraphIndex: Int,
+        val baselinePx: Float = bounds.bottom,
+        val textSizePx: Float = bounds.height,
+    ) : ReaderElement
+
+    data class Action(
+        override val bounds: ReaderRect,
+        val key: String,
+    ) : ReaderElement
+
+    data class Spacer(
+        override val bounds: ReaderRect,
+        val chapterPosition: Int,
+        val paragraphIndex: Int,
+    ) : ReaderElement
+
+    data class ParagraphMarker(
+        override val bounds: ReaderRect,
+        val colorArgb: Int,
+        val strokeWidthPx: Float,
+        val circular: Boolean,
+    ) : ReaderElement
+
+    data class Rule(
+        override val bounds: ReaderRect,
+        val colorArgb: Int,
+        val widthPx: Float,
+        val dashed: Boolean,
+        val dashOnPx: Float = 6f,
+        val dashOffPx: Float = 6f,
+        val overlayStyledUnderline: Boolean = false,
+    ) : ReaderElement
+}
+
+data class ReaderPage(
+    val id: ReaderPageId,
+    val chapterTitle: String,
+    val text: String,
+    val widthPx: Int,
+    val heightPx: Int,
+    val contentTopPx: Float,
+    val contentBottomPx: Float,
+    val elements: List<ReaderElement>,
+    val revision: Long,
+    /** Changes only when geometry/pagination changes; visual-only refreshes keep this stable. */
+    val layoutRevision: Long = revision,
+    val scrollExtentPx: Float = heightPx.toFloat(),
+    val decoration: ReaderPageDecoration = ReaderPageDecoration(),
+    val inlineImagesPreserveScrollLine: Boolean = true,
+    val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
+) {
+    fun elementAt(x: Float, y: Float): ReaderElement? =
+        elements.firstOrNull { it.bounds.contains(x, y) }
+
+    fun hasSameGeometryAs(other: ReaderPage): Boolean =
+        id == other.id &&
+            widthPx == other.widthPx && heightPx == other.heightPx &&
+            contentTopPx == other.contentTopPx && contentBottomPx == other.contentBottomPx &&
+            scrollExtentPx == other.scrollExtentPx &&
+            elements.size == other.elements.size &&
+            elements.indices.all { index ->
+                elements[index]::class == other.elements[index]::class &&
+                    elements[index].bounds == other.elements[index].bounds
+            }
+}
+
+data class ReaderPageWindow(
+    val previous: ReaderPage? = null,
+    val current: ReaderPage? = null,
+    val next: ReaderPage? = null,
+)
+
+enum class ReaderTipAlignment { START, CENTER, END }
+
+enum class ReaderTipVisual { TEXT, BATTERY_OUTER, BATTERY_INNER, BATTERY_ICON, BATTERY_CLASSIC, ARROW }
+
+data class ReaderPageTip(
+    val text: String,
+    val alignment: ReaderTipAlignment,
+    val visual: ReaderTipVisual = ReaderTipVisual.TEXT,
+    val batteryPercent: Int = 0,
+)
+
+data class ReaderTipRow(
+    val visible: Boolean,
+    val tips: List<ReaderPageTip>,
+    val colorArgb: Int,
+    val fontSizePx: Float,
+    val fontPath: String,
+    val paddingLeftPx: Float,
+    val paddingTopPx: Float,
+    val paddingRightPx: Float,
+    val paddingBottomPx: Float,
+    val dividerColorArgb: Int?,
+)
+
+data class ReaderPageDecoration(
+    val header: ReaderTipRow? = null,
+    val footer: ReaderTipRow? = null,
+    val bookmarkBadge: ReaderBookmarkBadge? = null,
+)

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

@@ -0,0 +1,64 @@
+package io.legado.app.feature.reader.core.model
+
+data class ReaderThemeColorChange(
+    val oldBodyArgb: Int,
+    val newBodyArgb: Int,
+    val oldTitleArgb: Int,
+    val newTitleArgb: Int,
+    val oldShadowArgb: Int = oldBodyArgb,
+    val newShadowArgb: Int = newBodyArgb,
+    val oldPageUnderlineArgb: Int = oldBodyArgb,
+    val newPageUnderlineArgb: Int = newBodyArgb,
+)
+
+/** Recolors an already-laid-out page so theme changes do not wait for asynchronous pagination. */
+fun ReaderPage.remapThemeColors(change: ReaderThemeColorChange, revisionSalt: Long): ReaderPage {
+    if (
+        change.oldBodyArgb == change.newBodyArgb &&
+        change.oldTitleArgb == change.newTitleArgb &&
+        change.oldShadowArgb == change.newShadowArgb &&
+        change.oldPageUnderlineArgb == change.newPageUnderlineArgb
+    ) return this
+    fun Int.mapped(from: Int, to: Int) = if (this == from) to else this
+    return copy(
+        elements = elements.map { element ->
+            when (element) {
+                is ReaderElement.Text -> {
+                    val oldColor = if (element.emphasized) change.oldTitleArgb else change.oldBodyArgb
+                    val newColor = if (element.emphasized) change.newTitleArgb else change.newBodyArgb
+                    element.copy(
+                        style = element.style.copy(
+                            colorArgb = element.style.colorArgb.mapped(oldColor, newColor),
+                            underline = element.style.underline?.copy(
+                                colorArgb = element.style.underline.colorArgb.mapped(oldColor, newColor),
+                            ),
+                            shadow = element.style.shadow?.copy(
+                                colorArgb = element.style.shadow.colorArgb.mapped(
+                                    change.oldShadowArgb,
+                                    change.newShadowArgb,
+                                ),
+                            ),
+                        ),
+                        emphasisUnderline = element.emphasisUnderline?.copy(
+                            colorArgb = element.emphasisUnderline.colorArgb.mapped(oldColor, newColor),
+                        ),
+                    )
+                }
+                is ReaderElement.Rule -> element.copy(
+                    colorArgb = element.colorArgb.mapped(
+                        change.oldPageUnderlineArgb,
+                        change.newPageUnderlineArgb,
+                    ),
+                )
+                is ReaderElement.ParagraphMarker -> element.copy(
+                    colorArgb = element.colorArgb.mapped(change.oldBodyArgb, change.newBodyArgb),
+                )
+                else -> element
+            }
+        },
+        emphasisUnderlineStyle = emphasisUnderlineStyle?.copy(
+            colorArgb = emphasisUnderlineStyle.colorArgb.mapped(change.oldBodyArgb, change.newBodyArgb),
+        ),
+        revision = revision xor revisionSalt,
+    )
+}

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

@@ -0,0 +1,49 @@
+package io.legado.app.feature.reader.core.model
+
+import kotlin.math.abs
+
+data class ReaderTextBackgroundRun(
+    val bounds: ReaderRect,
+    val contentBounds: ReaderRect,
+    val image: ReaderTextBackgroundImage,
+)
+
+fun ReaderPage.textBackgroundRuns(): List<ReaderTextBackgroundRun> {
+    val runs = mutableListOf<ReaderTextBackgroundRun>()
+    elements.filterIsInstance<ReaderElement.Text>().forEach { text ->
+        val image = text.style.backgroundImage ?: return@forEach
+        val previous = runs.lastOrNull()
+        if (
+            previous != null && previous.image == image &&
+            abs(previous.contentBounds.top - text.bounds.top) < 0.5f &&
+            abs(previous.contentBounds.bottom - text.bounds.bottom) < 0.5f &&
+            abs(previous.contentBounds.right - text.bounds.left) < 1f
+        ) {
+            runs[runs.lastIndex] = previous.copy(
+                bounds = previous.bounds.copy(
+                    right = text.bounds.right,
+                    top = minOf(previous.bounds.top, text.bounds.top - text.backgroundFrameTopPx),
+                    bottom = maxOf(previous.bounds.bottom, text.bounds.bottom + text.backgroundFrameBottomPx),
+                ),
+                contentBounds = previous.contentBounds.copy(right = text.bounds.right),
+            )
+        } else {
+            runs += ReaderTextBackgroundRun(
+                bounds = text.bounds.copy(
+                    top = text.bounds.top - text.backgroundFrameTopPx,
+                    bottom = text.bounds.bottom + text.backgroundFrameBottomPx,
+                ),
+                contentBounds = text.bounds,
+                image = image,
+            )
+        }
+    }
+    return runs.map { run ->
+        if (run.image.fit != 3) run else run.copy(
+            bounds = run.bounds.copy(
+                left = run.bounds.left - run.image.contentInsetLeftPx,
+                right = run.bounds.right + run.image.contentInsetRightPx,
+            ),
+        )
+    }
+}

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

@@ -0,0 +1,5 @@
+package io.legado.app.feature.reader.core.model
+
+/** Reader tip color value `0` means "follow body text color", not transparent. */
+fun resolveReaderTipColor(configuredColorArgb: Int, bodyTextColorArgb: Int): Int =
+    configuredColorArgb.takeUnless { it == 0 } ?: bodyTextColorArgb

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

@@ -0,0 +1,21 @@
+package io.legado.app.feature.reader.core.model
+
+object ReaderTipRowLayout {
+    fun lineHeight(fontTopPx: Float, fontBottomPx: Float): Float =
+        (fontBottomPx - fontTopPx).coerceAtLeast(0f)
+
+    fun extent(
+        paddingTopPx: Float,
+        fontTopPx: Float,
+        fontBottomPx: Float,
+        paddingBottomPx: Float,
+        dividerExtentPx: Float = 0f,
+    ): Float = paddingTopPx + lineHeight(fontTopPx, fontBottomPx) +
+        paddingBottomPx + dividerExtentPx
+
+    fun headerBaseline(paddingTopPx: Float, fontTopPx: Float): Float =
+        paddingTopPx - fontTopPx
+
+    fun footerBaseline(heightPx: Float, paddingBottomPx: Float, fontBottomPx: Float): Float =
+        heightPx - paddingBottomPx - fontBottomPx
+}

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

@@ -0,0 +1,82 @@
+package io.legado.app.feature.reader.core.model
+
+enum class ReaderTipValueType {
+    NONE,
+    CHAPTER_TITLE,
+    TIME,
+    BATTERY,
+    PAGE,
+    TOTAL_PROGRESS,
+    PAGE_AND_TOTAL,
+    BOOK_NAME,
+    TIME_BATTERY,
+    CHAPTER_INDEX_AND_TOTAL,
+    CHAPTER_TITLE_ARROW,
+    CUSTOM,
+    WHOLE_BOOK_PAGE,
+    WHOLE_BOOK_PAGE_AND_PROGRESS,
+}
+
+data class ReaderTipValueContext(
+    val bookName: String,
+    val chapterTitle: String,
+    val time: String,
+    val batteryPercent: Int,
+    val chapterIndex: Int,
+    val chapterCount: Int,
+    val pageIndex: Int,
+    val pageCount: Int,
+    val readProgress: String,
+    val wholeBookPageIndex: Int? = null,
+    val wholeBookPageCount: Int? = null,
+)
+
+object ReaderTipValueFormatter {
+    fun format(
+        type: ReaderTipValueType,
+        context: ReaderTipValueContext,
+        customTemplate: String = "",
+    ): String = with(context) {
+        when (type) {
+            ReaderTipValueType.NONE -> ""
+            ReaderTipValueType.CHAPTER_TITLE -> chapterTitle
+            ReaderTipValueType.TIME -> time
+            ReaderTipValueType.BATTERY -> "$batteryPercent%"
+            ReaderTipValueType.PAGE -> "${pageIndex + 1}/$pageCount"
+            ReaderTipValueType.TOTAL_PROGRESS -> readProgress
+            ReaderTipValueType.PAGE_AND_TOTAL -> "${pageIndex + 1}/$pageCount  $readProgress"
+            ReaderTipValueType.BOOK_NAME -> bookName
+            ReaderTipValueType.TIME_BATTERY -> "$time $batteryPercent%"
+            ReaderTipValueType.CHAPTER_INDEX_AND_TOTAL -> "${chapterIndex + 1}/$chapterCount"
+            ReaderTipValueType.CHAPTER_TITLE_ARROW -> chapterTitle
+            ReaderTipValueType.CUSTOM -> resolveCustom(customTemplate, context)
+            ReaderTipValueType.WHOLE_BOOK_PAGE -> wholeBookPage()
+            ReaderTipValueType.WHOLE_BOOK_PAGE_AND_PROGRESS -> "${wholeBookPage()}  $readProgress"
+        }
+    }
+
+    private fun ReaderTipValueContext.wholeBookPage(): String =
+        if (wholeBookPageIndex != null && wholeBookPageCount != null) {
+            "$wholeBookPageIndex/$wholeBookPageCount"
+        } else {
+            "${pageIndex + 1}/$pageCount"
+        }
+
+    private fun resolveCustom(template: String, context: ReaderTipValueContext): String {
+        val wholeIndex = context.wholeBookPageIndex ?: context.pageIndex + 1
+        val wholeCount = context.wholeBookPageCount ?: context.pageCount
+        return template
+            .replace("{BookName}", context.bookName)
+            .replace("{ChapterTitle}", context.chapterTitle)
+            .replace("{Time}", context.time)
+            .replace("{BatteryPercent}", "${context.batteryPercent}%")
+            .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("{ReadProgress}", context.readProgress)
+            .replace("{FullPageIndex}", wholeIndex.toString())
+            .replace("{FullPageSize}", wholeCount.toString())
+    }
+}

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

@@ -0,0 +1,30 @@
+package io.legado.app.feature.reader.core.model
+
+import kotlin.math.abs
+
+data class ReaderUnderlineRun(
+    val bounds: ReaderRect,
+    val underline: ReaderUnderline,
+)
+
+/** Coalesces adjacent styled text exactly once per visual line. */
+fun ReaderPage.underlineRuns(): List<ReaderUnderlineRun> {
+    val runs = mutableListOf<ReaderUnderlineRun>()
+    elements.filterIsInstance<ReaderElement.Text>().forEach { text ->
+        val underline = text.style.underline ?: return@forEach
+        val previous = runs.lastOrNull()
+        if (
+            previous != null && previous.underline == underline &&
+            abs(previous.bounds.top - text.bounds.top) < 0.5f &&
+            abs(previous.bounds.bottom - text.bounds.bottom) < 0.5f &&
+            abs(previous.bounds.right - text.bounds.left) < 1f
+        ) {
+            runs[runs.lastIndex] = previous.copy(
+                bounds = previous.bounds.copy(right = text.bounds.right),
+            )
+        } else {
+            runs += ReaderUnderlineRun(text.bounds, underline)
+        }
+    }
+    return runs
+}

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

@@ -0,0 +1,22 @@
+package io.legado.app.feature.reader.core.navigation
+
+data class ReaderChapterPaginationSnapshot(
+    val chapterIndex: Int,
+    val pageStarts: List<Int>,
+    val contentEnd: Int,
+    val generation: Long,
+) {
+    val pageCount: Int get() = pageStarts.size
+    val lastPageStart: Int? get() = pageStarts.lastOrNull()
+
+    fun pageIndex(chapterPosition: Int): Int = pageStarts
+        .indexOfLast { it <= chapterPosition }
+        .coerceAtLeast(0)
+        .coerceAtMost((pageStarts.size - 1).coerceAtLeast(0))
+
+    fun pageStart(pageIndex: Int): Int? = pageStarts.getOrNull(pageIndex)
+
+    fun nextPageStart(chapterPosition: Int): Int? = pageStarts.getOrNull(pageIndex(chapterPosition) + 1)
+
+    fun previousPageStart(chapterPosition: Int): Int? = pageStarts.getOrNull(pageIndex(chapterPosition) - 1)
+}

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

@@ -0,0 +1,127 @@
+package io.legado.app.feature.reader.core.navigation
+
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderPageWindow
+
+data class ReaderNavigationResult(
+    val pageIndex: Int,
+    val window: ReaderPageWindow,
+    val hitBoundary: Boolean,
+)
+
+data class ReaderChapterPagePosition(
+    val chapterIndex: Int,
+    val pageIndex: Int,
+    val pageCount: Int,
+)
+
+data class ReaderPageContext(
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val startPosition: Int,
+    val endPosition: Int,
+    val text: String,
+    val contentStartPosition: Int?,
+    val anchorText: String?,
+)
+
+object ReaderPageNavigator {
+    fun bodyParagraphAt(pages: List<ReaderPage>, chapterIndex: Int, chapterPosition: Int): Int? = pages
+        .asSequence()
+        .filter { it.id.chapterIndex == chapterIndex }
+        .flatMap { it.elements.asSequence() }
+        .filterIsInstance<ReaderElement.Text>()
+        .filter { !it.emphasized && it.paragraphIndex >= 0 && it.chapterPosition <= chapterPosition }
+        .maxByOrNull { it.chapterPosition }
+        ?.paragraphIndex
+
+    fun window(pages: List<ReaderPage>, pageIndex: Int): ReaderPageWindow {
+        if (pages.isEmpty()) return ReaderPageWindow()
+        val index = pageIndex.coerceIn(pages.indices)
+        return ReaderPageWindow(
+            previous = pages.getOrNull(index - 1),
+            current = pages[index],
+            next = pages.getOrNull(index + 1),
+        )
+    }
+
+    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)
+        return ReaderNavigationResult(target, window(pages, target), target != pageIndex + delta)
+    }
+
+    fun locate(pages: List<ReaderPage>, chapterIndex: Int, chapterPosition: Int): Int {
+        val chapterPages = pages.withIndex().filter { it.value.id.chapterIndex == chapterIndex }
+        if (chapterPages.isEmpty()) return 0
+        return chapterPages.lastOrNull { pageStart(it.value) <= chapterPosition }?.index
+            ?: chapterPages.first().index
+    }
+
+    fun chapterPosition(pages: List<ReaderPage>, pageIndex: Int): ReaderChapterPagePosition? {
+        val page = pages.getOrNull(pageIndex) ?: return null
+        val chapterIndex = page.id.chapterIndex
+        val chapterPages = pages.filter { it.id.chapterIndex == chapterIndex }
+        val localIndex = chapterPages.indexOfFirst { it === page }
+            .takeIf { it >= 0 }
+            ?: chapterPages.indexOfFirst { it.id == page.id }
+        if (localIndex < 0) return null
+        return ReaderChapterPagePosition(chapterIndex, localIndex, chapterPages.size)
+    }
+
+    fun locateChapterPage(
+        pages: List<ReaderPage>,
+        chapterIndex: Int,
+        chapterPageIndex: Int,
+    ): Int? {
+        val chapterPages = pages.withIndex().filter { it.value.id.chapterIndex == chapterIndex }
+        if (chapterPages.isEmpty()) return null
+        return chapterPages[chapterPageIndex.coerceIn(chapterPages.indices)].index
+    }
+
+    fun pageContext(pages: List<ReaderPage>, pageIndex: Int): ReaderPageContext? {
+        val page = pages.getOrNull(pageIndex) ?: return null
+        val positions = page.elements.mapNotNull(::elementRange)
+        if (positions.isEmpty() && page.elements.none { it is ReaderElement.Text && it.emphasized }) return null
+        val start = positions.minOfOrNull { it.first } ?: 0
+        val contentEnd = positions.maxOfOrNull { it.last + 1 } ?: 0
+        val nextPageStart = pages.getOrNull(pageIndex + 1)
+            ?.takeIf { it.id.chapterIndex == page.id.chapterIndex }
+            ?.elements
+            ?.mapNotNull(::elementRange)
+            ?.minOfOrNull { it.first }
+            ?.takeIf { it > start }
+        val firstBodyParagraph = page.elements.asSequence()
+            .filterIsInstance<ReaderElement.Text>()
+            .filterNot { it.emphasized }
+            .groupBy { it.paragraphIndex }
+            .values
+            .minByOrNull { paragraph ->
+                paragraph.minOf { it.chapterPosition }
+            }
+        return ReaderPageContext(
+            chapterIndex = page.id.chapterIndex,
+            chapterTitle = page.chapterTitle,
+            startPosition = start,
+            endPosition = nextPageStart ?: contentEnd.coerceAtLeast(start + 1),
+            text = page.text,
+            contentStartPosition = firstBodyParagraph?.minOf { it.chapterPosition },
+            anchorText = firstBodyParagraph
+                ?.joinToString(separator = "") { it.value }
+                ?.trim()
+                ?.takeIf(String::isNotEmpty),
+        )
+    }
+
+    fun pageStart(page: ReaderPage): Int = page.elements.mapNotNull(::elementRange)
+        .minOfOrNull { it.first } ?: 0
+
+    private fun elementRange(element: ReaderElement): IntRange? = when (element) {
+        is ReaderElement.Text -> if (element.emphasized) null else element.chapterPosition until
+            (element.chapterPosition + element.value.length.coerceAtLeast(1))
+        is ReaderElement.Image -> element.chapterPosition..element.chapterPosition
+        is ReaderElement.Spacer -> element.chapterPosition..element.chapterPosition
+        else -> null
+    }
+}

+ 95 - 0
app/src/main/java/io/legado/app/feature/reader/core/readaloud/ReaderReadAloudChapter.kt

@@ -0,0 +1,95 @@
+package io.legado.app.feature.reader.core.readaloud
+
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+
+data class ReaderReadAloudParagraph(
+    val text: String,
+    val chapterPosition: Int,
+    val isParagraphEnd: Boolean,
+) {
+    val endPosition: Int get() = chapterPosition + text.length
+}
+
+data class ReaderReadAloudChapter(
+    val chapterIndex: Int,
+    val title: String,
+    val pageStarts: List<Int>,
+    val paragraphs: List<ReaderReadAloudParagraph>,
+    val pageParagraphs: List<ReaderReadAloudParagraph>,
+    val chapterLength: Int,
+) {
+    val pageCount: Int get() = pageStarts.size
+
+    fun pageStart(index: Int): Int = pageStarts.getOrElse(index) { pageStarts.lastOrNull() ?: 0 }
+
+    fun pageIndexAt(position: Int): Int = pageStarts
+        .indexOfLast { it <= position }
+        .coerceAtLeast(0)
+        .coerceAtMost((pageStarts.size - 1).coerceAtLeast(0))
+
+    fun paragraphs(splitByPage: Boolean): List<ReaderReadAloudParagraph> =
+        if (splitByPage) pageParagraphs else paragraphs
+
+    fun paragraphIndexAtOrAfter(position: Int, splitByPage: Boolean): Int =
+        paragraphs(splitByPage).indexOfFirst { it.endPosition >= position }
+
+    fun canonicalSpeechParagraphs(): List<CanonicalSpeechParagraph> = paragraphs.mapIndexed {
+        index, paragraph ->
+        CanonicalSpeechParagraph(index, paragraph.text.sanitizeForSpeech(), paragraph.chapterPosition)
+    }
+
+    companion object {
+        fun create(
+            chapterIndex: Int,
+            title: String,
+            semanticContent: String,
+            pageStarts: List<Int>,
+        ): ReaderReadAloudChapter {
+            val normalizedStarts = pageStarts
+                .asSequence()
+                .filter { it >= 0 }
+                .distinct()
+                .sorted()
+                .toList()
+                .ifEmpty { listOf(0) }
+            val paragraphs = semanticContent.lineParagraphs()
+            val pageParagraphs = paragraphs.flatMap { paragraph ->
+                val cuts = normalizedStarts.filter { it > paragraph.chapterPosition && it < paragraph.endPosition }
+                buildList {
+                    var start = paragraph.chapterPosition
+                    (cuts + paragraph.endPosition).forEach { end ->
+                        if (end > start) {
+                            add(ReaderReadAloudParagraph(
+                                text = semanticContent.substring(start, end),
+                                chapterPosition = start,
+                                isParagraphEnd = end == paragraph.endPosition,
+                            ))
+                        }
+                        start = end
+                    }
+                }
+            }
+            return ReaderReadAloudChapter(
+                chapterIndex = chapterIndex,
+                title = title,
+                pageStarts = normalizedStarts,
+                paragraphs = paragraphs,
+                pageParagraphs = pageParagraphs,
+                chapterLength = paragraphs.lastOrNull()?.endPosition ?: 0,
+            )
+        }
+    }
+}
+
+private fun String.lineParagraphs(): List<ReaderReadAloudParagraph> = buildList {
+    var start = 0
+    this@lineParagraphs.forEachIndexed { index, character ->
+        if (character == '\n') {
+            if (index > start) add(ReaderReadAloudParagraph(substring(start, index), start, true))
+            start = index + 1
+        }
+    }
+    if (start < length) add(ReaderReadAloudParagraph(substring(start), start, true))
+}
+
+private fun String.sanitizeForSpeech(): String = replace(Regex("[袮祢꧁]"), " ")

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

@@ -0,0 +1,44 @@
+package io.legado.app.feature.reader.core.selection
+
+data class ReaderSearchRequest(
+    val directIndex: Int,
+    val directLength: Int,
+    val occurrence: Int,
+    val isRegex: Boolean,
+)
+
+data class ReaderSearchMatch(val start: Int, val length: Int)
+
+/** Resolves a search result against the latest processed chapter text, without page coordinates. */
+object ReaderSearchMatcher {
+    fun find(content: String, query: String, request: ReaderSearchRequest): ReaderSearchMatch? {
+        if (query.isEmpty()) return null
+        val directLength = request.directLength.takeIf { it > 0 } ?: query.length
+        val directIndex = request.directIndex
+        if (directIndex >= 0 && directIndex + directLength <= content.length) {
+            val matches = if (request.isRegex) {
+                runCatching {
+                    Regex(query).matches(content.substring(directIndex, directIndex + directLength))
+                }.getOrDefault(false)
+            } else {
+                content.regionMatches(directIndex, query, 0, query.length, ignoreCase = false)
+            }
+            if (matches) return ReaderSearchMatch(directIndex, directLength)
+        }
+        if (request.isRegex) {
+            return runCatching {
+                Regex(query).findAll(content)
+                    .drop(request.occurrence.coerceAtLeast(0))
+                    .firstOrNull()
+                    ?.let { ReaderSearchMatch(it.range.first, it.value.length) }
+            }.getOrNull()
+        }
+        var occurrence = 0
+        var index = content.indexOf(query)
+        while (occurrence != request.occurrence.coerceAtLeast(0) && index >= 0) {
+            index = content.indexOf(query, index + query.length)
+            occurrence += 1
+        }
+        return index.takeIf { it >= 0 }?.let { ReaderSearchMatch(it, query.length) }
+    }
+}

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

@@ -0,0 +1,190 @@
+package io.legado.app.feature.reader.core.selection
+
+import androidx.compose.runtime.Stable
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderRect
+import java.text.BreakIterator
+import java.util.Locale
+
+enum class ReaderSelectionEndpoint {
+    ANCHOR,
+    FOCUS,
+}
+
+@Stable
+data class ReaderSelection(
+    val chapterIndex: Int,
+    val anchor: Int,
+    val focus: Int,
+    val anchorIsTitle: Boolean = false,
+    val focusIsTitle: Boolean = anchorIsTitle,
+) {
+    // Title offsets and body offsets are independent. Document order puts the title first.
+    private val forward: Boolean get() = comparePosition(anchor, anchorIsTitle, focus, focusIsTitle) <= 0
+    val start: Int get() = if (forward) anchor else focus
+    val endInclusive: Int get() = if (forward) focus else anchor
+    val includesTitle: Boolean get() = anchorIsTitle || focusIsTitle
+    val bodyStart: Int? get() = when {
+        anchorIsTitle && focusIsTitle -> null
+        includesTitle -> 0
+        else -> start
+    }
+
+    fun contains(element: ReaderElement.Text): Boolean =
+        comparePosition(element.chapterPosition, element.emphasized, start, if (forward) anchorIsTitle else focusIsTitle) >= 0 &&
+            comparePosition(element.chapterPosition, element.emphasized, endInclusive, if (forward) focusIsTitle else anchorIsTitle) <= 0
+
+    fun moveStart(position: Int, isTitle: Boolean = false): ReaderSelection =
+        if (forward) copy(anchor = position, anchorIsTitle = isTitle)
+        else copy(focus = position, focusIsTitle = isTitle)
+
+    fun moveEnd(position: Int, isTitle: Boolean = false): ReaderSelection =
+        if (forward) copy(focus = position, focusIsTitle = isTitle)
+        else copy(anchor = position, anchorIsTitle = isTitle)
+
+    fun visualStartEndpoint(): ReaderSelectionEndpoint =
+        if (forward) ReaderSelectionEndpoint.ANCHOR else ReaderSelectionEndpoint.FOCUS
+
+    fun visualEndEndpoint(): ReaderSelectionEndpoint =
+        if (forward) ReaderSelectionEndpoint.FOCUS else ReaderSelectionEndpoint.ANCHOR
+
+    fun moveEndpoint(
+        endpoint: ReaderSelectionEndpoint,
+        position: Int,
+        isTitle: Boolean = false,
+    ): ReaderSelection = when (endpoint) {
+        ReaderSelectionEndpoint.ANCHOR -> copy(anchor = position, anchorIsTitle = isTitle)
+        ReaderSelectionEndpoint.FOCUS -> copy(focus = position, focusIsTitle = isTitle)
+    }
+
+    fun selectedText(page: ReaderPage): String {
+        return selectedText(listOf(page))
+    }
+
+    /** Collects a selection across every available page without duplicating page-boundary glyphs. */
+    fun selectedText(pages: List<ReaderPage>): String {
+        val elements = pages.asSequence()
+            .filter { it.id.chapterIndex == chapterIndex }
+            .flatMap { it.elements.asSequence().filterIsInstance<ReaderElement.Text>() }
+            .filter(::contains)
+            .distinctBy { Triple(it.emphasized, it.chapterPosition, it.value) }
+            .sortedWith(documentOrder)
+            .toList()
+        return buildString {
+            var previous: ReaderElement.Text? = null
+            elements.forEach { element ->
+                previous?.let { prior ->
+                    if (prior.emphasized != element.emphasized ||
+                        (prior.paragraphIndex >= 0 && element.paragraphIndex >= 0 &&
+                            prior.paragraphIndex != element.paragraphIndex)
+                    ) append('\n')
+                }
+                append(element.value)
+                previous = element
+            }
+        }
+    }
+
+    fun bounds(page: ReaderPage): List<ReaderRect> = if (page.id.chapterIndex != chapterIndex) emptyList() else page.elements
+        .filterIsInstance<ReaderElement.Text>()
+        .filter(::contains)
+        .sortedWith(documentOrder)
+        .map(ReaderElement.Text::bounds)
+
+    private companion object {
+        val documentOrder = compareBy<ReaderElement.Text> { !it.emphasized }.thenBy { it.chapterPosition }
+
+        fun comparePosition(left: Int, leftIsTitle: Boolean, right: Int, rightIsTitle: Boolean): Int =
+            if (leftIsTitle == rightIsTitle) left.compareTo(right)
+            else if (leftIsTitle) -1 else 1
+    }
+}
+
+object ReaderSelectionPolicy {
+    fun start(page: ReaderPage, x: Float, y: Float): ReaderSelection? =
+        (page.elementAt(x, y) as? ReaderElement.Text)?.let {
+            ReaderSelection(page.id.chapterIndex, it.chapterPosition, it.chapterPosition, it.emphasized)
+        }
+
+    /**
+     * Selection handles hang below the text row, so a handle drag often moves through the
+     * leading where [ReaderPage.elementAt] misses. Snap a miss to the nearest row by vertical
+     * distance, then to the glyph closest to the finger's x within that row.
+     */
+    fun snapToText(page: ReaderPage, x: Float, y: Float): ReaderElement.Text? {
+        (page.elementAt(x, y) as? ReaderElement.Text)?.let { return it }
+        val textElements = page.elements.filterIsInstance<ReaderElement.Text>()
+        if (textElements.isEmpty()) return null
+        fun verticalDistance(bounds: ReaderRect): Float =
+            (y - bounds.bottom).coerceAtLeast(0f).coerceAtLeast(bounds.top - y)
+        val nearest = textElements.minByOrNull { verticalDistance(it.bounds) } ?: return null
+        if (verticalDistance(nearest.bounds) > nearest.bounds.height) return null
+        return textElements
+            .filter { it.bounds.bottom > nearest.bounds.top && it.bounds.top < nearest.bounds.bottom }
+            .minByOrNull { element ->
+                val bounds = element.bounds
+                when {
+                    x < bounds.left -> bounds.left - x
+                    x > bounds.right -> x - bounds.right
+                    else -> 0f
+                }
+            }
+    }
+
+    /** Matches the View reader's long-press behavior: select one word in the hit paragraph. */
+    fun startWord(
+        page: ReaderPage,
+        x: Float,
+        y: Float,
+        locale: Locale = Locale.getDefault(),
+    ): ReaderSelection? {
+        val hit = page.elementAt(x, y) as? ReaderElement.Text ?: return null
+        val paragraph = page.elements.filterIsInstance<ReaderElement.Text>()
+            .filter {
+                it.emphasized == hit.emphasized &&
+                    (hit.paragraphIndex < 0 || it.paragraphIndex == hit.paragraphIndex)
+            }
+            .sortedBy(ReaderElement.Text::chapterPosition)
+        val hitIndex = paragraph.indexOf(hit)
+        if (hitIndex < 0) return null
+
+        val text = paragraph.joinToString(separator = "", transform = ReaderElement.Text::value)
+        val hitOffset = paragraph.take(hitIndex).sumOf { it.value.length }
+        val boundary = BreakIterator.getWordInstance(locale).apply { setText(text) }
+        var start = boundary.first()
+        var end = boundary.next()
+        while (end != BreakIterator.DONE && hitOffset !in start until end) {
+            start = end
+            end = boundary.next()
+        }
+        if (end == BreakIterator.DONE) {
+            return ReaderSelection(page.id.chapterIndex, hit.chapterPosition, hit.chapterPosition, hit.emphasized)
+        }
+
+        var offset = 0
+        var first: ReaderElement.Text? = null
+        var last: ReaderElement.Text? = null
+        paragraph.forEach { element ->
+            val elementEnd = offset + element.value.length
+            if (offset < end && elementEnd > start) {
+                if (first == null) first = element
+                last = element
+            }
+            offset = elementEnd
+        }
+        return ReaderSelection(
+            chapterIndex = page.id.chapterIndex,
+            anchor = first?.chapterPosition ?: hit.chapterPosition,
+            focus = last?.chapterPosition ?: hit.chapterPosition,
+            anchorIsTitle = hit.emphasized,
+        )
+    }
+
+    fun extend(selection: ReaderSelection, page: ReaderPage, x: Float, y: Float): ReaderSelection {
+        if (selection.chapterIndex != page.id.chapterIndex) return selection
+        return (page.elementAt(x, y) as? ReaderElement.Text)?.let {
+            selection.copy(focus = it.chapterPosition, focusIsTitle = it.emphasized)
+        } ?: selection
+    }
+}

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

@@ -0,0 +1,31 @@
+package io.legado.app.feature.reader.core.selection
+
+import io.legado.app.feature.reader.core.model.ReaderRect
+/** Joins sequential glyph boxes on the same visual line into one continuous selection band. */
+fun List<ReaderRect>.mergeSelectionBounds(minimumLineOverlap: Float = 0.5f): List<ReaderRect> {
+    if (size < 2) return this
+    val result = ArrayList<ReaderRect>(size)
+    forEach { rect ->
+        val previous = result.lastOrNull()
+        val verticalOverlap = previous?.let {
+            (minOf(it.bottom, rect.bottom) - maxOf(it.top, rect.top)).coerceAtLeast(0f)
+        } ?: 0f
+        val sameLine = previous != null && verticalOverlap >=
+            minOf(previous.height, rect.height) * minimumLineOverlap.coerceIn(0f, 1f)
+        val joinGapPx = previous?.let { minOf(it.height, rect.height) * 0.5f } ?: 0f
+        val horizontalGap = previous?.let {
+            maxOf(rect.left - it.right, it.left - rect.right, 0f)
+        } ?: Float.POSITIVE_INFINITY
+        if (sameLine && horizontalGap <= joinGapPx) {
+            result[result.lastIndex] = ReaderRect(
+                left = minOf(previous.left, rect.left),
+                top = minOf(previous.top, rect.top),
+                right = maxOf(previous.right, rect.right),
+                bottom = maxOf(previous.bottom, rect.bottom),
+            )
+        } else {
+            result += rect
+        }
+    }
+    return result
+}

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

@@ -0,0 +1,28 @@
+package io.legado.app.feature.reader.core.selection
+
+data class ReaderSelectionLine(
+    val chapterPosition: Int,
+    val columnCharacterLengths: List<Int?>,
+)
+
+data class ReaderSelectionCoordinate(
+    val lineIndex: Int,
+    val columnIndex: Int,
+)
+
+object ReaderSelectionCoordinateMapper {
+    fun find(lines: List<ReaderSelectionLine>, chapterPosition: Int): ReaderSelectionCoordinate? {
+        lines.forEachIndexed { lineIndex, line ->
+            var position = line.chapterPosition
+            line.columnCharacterLengths.forEachIndexed { columnIndex, rawLength ->
+                if (rawLength == null) return@forEachIndexed
+                val next = position + rawLength.coerceAtLeast(1)
+                if (chapterPosition in position until next) {
+                    return ReaderSelectionCoordinate(lineIndex, columnIndex)
+                }
+                position = next
+            }
+        }
+        return null
+    }
+}

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

@@ -0,0 +1,20 @@
+package io.legado.app.feature.reader.core.selection
+
+enum class ReaderPageChangeOrigin {
+    PROGRAMMATIC,
+    SELECTION_DRAG_SCROLL,
+}
+
+object ReaderSelectionLifecyclePolicy {
+    fun shouldClearForPageChange(origin: ReaderPageChangeOrigin): Boolean =
+        origin == ReaderPageChangeOrigin.PROGRAMMATIC
+
+    fun shouldPauseAutoPage(hasSelection: Boolean): Boolean = hasSelection
+
+    fun shouldReanchorMenuAfterLayoutChange(
+        hasSelection: Boolean,
+        menuVisible: Boolean,
+        previousLayoutRevision: Long,
+        currentLayoutRevision: Long,
+    ): Boolean = hasSelection && menuVisible && previousLayoutRevision != currentLayoutRevision
+}

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

@@ -0,0 +1,25 @@
+package io.legado.app.feature.reader.core.selection
+
+import io.legado.app.feature.reader.core.model.ReaderRect
+
+data class ReaderSelectionMenuAnchor(
+    val startX: Float,
+    val startTopY: Float,
+    val startBottomY: Float,
+    val endX: Float,
+    val endBottomY: Float,
+) {
+    companion object {
+        fun from(bounds: List<ReaderRect>): ReaderSelectionMenuAnchor? {
+            val first = bounds.firstOrNull() ?: return null
+            val last = bounds.last()
+            return ReaderSelectionMenuAnchor(
+                startX = first.left,
+                startTopY = first.top,
+                startBottomY = first.bottom,
+                endX = last.right,
+                endBottomY = last.bottom,
+            )
+        }
+    }
+}

+ 162 - 0
app/src/main/java/io/legado/app/feature/reader/core/source/ReaderChapterSource.kt

@@ -0,0 +1,162 @@
+package io.legado.app.feature.reader.core.source
+
+sealed interface ReaderChapterSourceBlock {
+    val chapterPosition: Int
+
+    data class Text(
+        val value: String,
+        override val chapterPosition: Int,
+        val isTitle: Boolean = false,
+        val fontSizeScale: Float = 1f,
+        val isSubtitle: Boolean = false,
+    ) : ReaderChapterSourceBlock
+
+    data class Image(
+        val source: String,
+        override val chapterPosition: Int,
+    ) : ReaderChapterSourceBlock
+
+    data class Paragraph(
+        val items: List<ReaderChapterInlineSource>,
+        override val chapterPosition: Int,
+    ) : ReaderChapterSourceBlock
+
+    data class Html(
+        val value: String,
+        override val chapterPosition: Int,
+        val semanticLength: Int = value.length,
+    ) : ReaderChapterSourceBlock
+
+    data class PageBreak(override val chapterPosition: Int) : ReaderChapterSourceBlock
+}
+
+sealed interface ReaderChapterInlineSource {
+    val chapterPosition: Int
+
+    data class Text(
+        val value: String,
+        override val chapterPosition: Int,
+        val style: ReaderInlineSourceStyle = ReaderInlineSourceStyle(),
+    ) : ReaderChapterInlineSource
+    data class Image(val source: String, override val chapterPosition: Int) : ReaderChapterInlineSource
+    data class BlankLine(override val chapterPosition: Int) : ReaderChapterInlineSource
+}
+
+data class ReaderInlineSourceStyle(
+    val colorArgb: Int? = null,
+    val backgroundArgb: Int? = null,
+    val fontWeight: Int? = null,
+    val italic: Boolean? = null,
+    val underline: Boolean = false,
+    val strikeThrough: Boolean = false,
+    val link: String? = null,
+    val fontSizeScale: Float = 1f,
+    val fontFamily: String? = null,
+    val superscript: Boolean = false,
+    val subscript: Boolean = false,
+)
+
+data class ReaderChapterSource(
+    val chapterIndex: Int,
+    val title: String,
+    val blocks: List<ReaderChapterSourceBlock>,
+    val characterCount: Int,
+    /** Body text in the exact character-position space used by Canvas selection/navigation. */
+    val semanticContent: String = "",
+) {
+    /** Rebuild presentation-only titles without changing body anchors or reloading content. */
+    fun withTitleVisibility(
+        visible: Boolean,
+        segmentation: ReaderTitleSegmentation = ReaderTitleSegmentation(),
+    ): ReaderChapterSource {
+        val body = blocks.filterNot { it is ReaderChapterSourceBlock.Text && it.isTitle }
+        val titles = if (visible) segmentation.blocks(title) else emptyList()
+        return copy(blocks = titles + body)
+    }
+
+    /** Highlight rules use the displayed segments, including inserted paragraph separators. */
+    val semanticTitle: String
+        get() = blocks.filterIsInstance<ReaderChapterSourceBlock.Text>()
+            .filter { it.isTitle }.joinToString("") { "${it.value}\n" }
+}
+
+fun interface ReaderHtmlSemanticTextResolver {
+    fun resolve(html: String): String
+}
+
+/** Converts processed BookContent paragraphs into renderer-owned semantic blocks. */
+object ReaderChapterSourceParser {
+    private val imagePattern = Regex("<img[^>]*src=\"([^\"]*(?:\"[^>]+\\})?)\"[^>]*>")
+
+    fun parse(
+        chapterIndex: Int,
+        title: String,
+        paragraphs: List<String>,
+        includeTitle: Boolean,
+        adaptSpecialStyle: Boolean,
+        htmlSemanticTextResolver: ReaderHtmlSemanticTextResolver = ReaderHtmlSemanticTextResolver(::htmlSemanticText),
+    ): ReaderChapterSource {
+        val result = mutableListOf<ReaderChapterSourceBlock>()
+        val semanticContent = StringBuilder()
+        var position = 0
+        if (includeTitle && title.isNotBlank()) {
+            result += ReaderTitleSegmentation().blocks(title)
+        }
+        paragraphs.forEach { rawParagraph ->
+            val trimmed = rawParagraph.trim()
+            if (adaptSpecialStyle && trimmed == "[newpage]") {
+                result += ReaderChapterSourceBlock.PageBreak(position)
+                return@forEach
+            }
+            if (adaptSpecialStyle && trimmed.startsWith("<usehtml>") && trimmed.endsWith("</usehtml>")) {
+                val html = trimmed.removePrefix("<usehtml>").removeSuffix("</usehtml>")
+                val semanticText = htmlSemanticTextResolver.resolve(html)
+                val semanticLength = semanticText.length
+                result += ReaderChapterSourceBlock.Html(html, position, semanticLength)
+                semanticContent.append(semanticText).append('\n')
+                position += semanticLength + 1
+                return@forEach
+            }
+            val inlineItems = mutableListOf<ReaderChapterInlineSource>()
+            val paragraphPosition = position
+            var cursor = 0
+            imagePattern.findAll(rawParagraph).forEach { match ->
+                if (cursor < match.range.first) {
+                    val text = rawParagraph.substring(cursor, match.range.first)
+                    if (text.isNotEmpty()) {
+                        inlineItems += ReaderChapterInlineSource.Text(text, position)
+                        semanticContent.append(text)
+                        position += text.length
+                    }
+                }
+                inlineItems += ReaderChapterInlineSource.Image(match.groupValues[1], position)
+                semanticContent.append('\uFFFC')
+                position += 1
+                cursor = match.range.last + 1
+            }
+            if (cursor < rawParagraph.length) {
+                val text = rawParagraph.substring(cursor)
+                if (text.isNotEmpty()) {
+                    inlineItems += ReaderChapterInlineSource.Text(text, position)
+                    semanticContent.append(text)
+                    position += text.length
+                }
+            }
+            if (inlineItems.isNotEmpty()) {
+                result += ReaderChapterSourceBlock.Paragraph(inlineItems, paragraphPosition)
+            }
+            semanticContent.append('\n')
+            position += 1 // Paragraph separator uses the same chapter-position unit as legacy layout.
+        }
+        return ReaderChapterSource(chapterIndex, title, result, position, semanticContent.toString())
+    }
+
+    private fun htmlSemanticText(html: String): String = html
+        .replace(imagePattern, "\uFFFC")
+        .replace(Regex("<(br|/p|/div|/li)\\b[^>]*>", RegexOption.IGNORE_CASE), "\n")
+        .replace(Regex("<[^>]+>"), "")
+        .replace("&nbsp;", " ")
+        .replace("&lt;", "<")
+        .replace("&gt;", ">")
+        .replace("&amp;", "&")
+}

+ 41 - 0
app/src/main/java/io/legado/app/feature/reader/core/source/ReaderTitleSegmentation.kt

@@ -0,0 +1,41 @@
+package io.legado.app.feature.reader.core.source
+
+/** Persisted title options, captured once for a pagination pass. */
+data class ReaderTitleSegmentation(
+    val type: Int = 0,
+    val distance: Int = 4,
+    val delimiter: String = "",
+    val subtitleScale: Float = 1f,
+) {
+    fun blocks(title: String): List<ReaderChapterSourceBlock.Text> {
+        var position = 0
+        return title.split('\n').map(String::trim).filter(String::isNotBlank).flatMap { line ->
+            split(line).mapIndexed { index, text ->
+                ReaderChapterSourceBlock.Text(
+                    value = text,
+                    chapterPosition = position,
+                    isTitle = true,
+                    fontSizeScale = if (index == 0) 1f else subtitleScale,
+                    isSubtitle = index > 0,
+                ).also { position += text.length + 1 }
+            }
+        }
+    }
+
+    private fun split(title: String): List<String> = when (type) {
+        1 -> if (distance in 1 until title.length) {
+            listOf(title.take(distance), title.substring(distance))
+        } else listOf(title)
+        2, 3 -> {
+            val pattern = if (type == 3) delimiter else delimiter.split(',')
+                .map(String::trim).filter(String::isNotEmpty).joinToString("|", transform = Regex::escape)
+            if (pattern.isEmpty()) listOf(title) else {
+                // A malformed imported expression must not make the chapter unreadable.
+                val regex = runCatching { Regex("(?<=$pattern)") }.getOrNull()
+                if (regex == null) listOf(title) else title.split(regex)
+                    .map(String::trim).filter(String::isNotEmpty)
+            }
+        }
+        else -> listOf(title)
+    }
+}

+ 41 - 0
app/src/main/java/io/legado/app/feature/reader/core/style/ReaderCharacterStyle.kt

@@ -0,0 +1,41 @@
+package io.legado.app.feature.reader.core.style
+
+import io.legado.app.feature.reader.core.model.ReaderUnderline
+import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
+
+enum class ReaderStyleTarget { ALL, TITLE, BODY }
+
+data class ReaderCharacterStyle(
+    val colorArgb: Int? = null,
+    val backgroundArgb: Int? = null,
+    val underline: ReaderUnderline? = null,
+    val fontPath: String? = null,
+    val fontWeight: Int? = null,
+    val italic: Boolean? = null,
+    val fontSizeOffsetPx: Float = 0f,
+    val markingId: String? = null,
+    val backgroundImage: ReaderTextBackgroundImage? = null,
+)
+
+data class ReaderStyleRange(
+    val start: Int,
+    val endExclusive: Int,
+    val target: ReaderStyleTarget,
+    val style: ReaderCharacterStyle,
+    val priority: Int = 0,
+) {
+    fun contains(position: Int, isTitle: Boolean): Boolean =
+        position in start until endExclusive && when (target) {
+            ReaderStyleTarget.ALL -> true
+            ReaderStyleTarget.TITLE -> isTitle
+            ReaderStyleTarget.BODY -> !isTitle
+        }
+}
+
+object ReaderCharacterStyleResolver {
+    fun resolve(ranges: List<ReaderStyleRange>, position: Int, isTitle: Boolean): ReaderCharacterStyle? =
+        ranges.withIndex().asSequence()
+            .filter { it.value.contains(position, isTitle) }
+            .maxWithOrNull(compareBy<IndexedValue<ReaderStyleRange>> { it.value.priority }.thenBy { it.index })
+            ?.value?.style
+}

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

@@ -0,0 +1,182 @@
+package io.legado.app.feature.reader.core.transition
+
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.atan2
+import kotlin.math.hypot
+
+data class CurlPoint(val x: Float, val y: Float) {
+    val isFinite: Boolean get() = x.isFinite() && y.isFinite()
+}
+
+data class CurlMirror(
+    val scaleX: Float,
+    val skewX: Float,
+    val skewY: Float,
+    val scaleY: Float,
+    val translateX: Float,
+    val translateY: Float,
+)
+
+data class PageCurlFrame(
+    val touch: CurlPoint,
+    val corner: CurlPoint,
+    val control1: CurlPoint,
+    val control2: CurlPoint,
+    val start1: CurlPoint,
+    val start2: CurlPoint,
+    val end1: CurlPoint,
+    val end2: CurlPoint,
+    val vertex1: CurlPoint,
+    val vertex2: CurlPoint,
+    val degrees: Float,
+    val touchToCornerDistance: Float,
+    val mirror: CurlMirror,
+) {
+    val isValid: Boolean get() = listOf(
+        touch, corner, control1, control2, start1, start2, end1, end2, vertex1, vertex2,
+    ).all(CurlPoint::isFinite)
+}
+
+object ReaderCurlVisualPolicy {
+    val backShadowDarkArgb: Int = 0xFF111111.toInt()
+    val frontShadowDarkArgb: Int = 0x80111111.toInt()
+    val folderShadowDarkArgb: Int = 0xB0333333.toInt()
+}
+
+object ReaderCurlTouchPolicy {
+    fun settleDurationMillis(
+        currentX: Float,
+        targetX: Float,
+        pageWidth: Float,
+        baseDurationMillis: Int = 300,
+    ): Int {
+        if (pageWidth <= 0f || baseDurationMillis <= 0) return 0
+        return (baseDurationMillis * abs(targetX - currentX) / pageWidth).toInt()
+    }
+
+    fun programmaticX(
+        direction: ReaderTurnDirection,
+        pageWidth: Float,
+    ): Float = when (direction) {
+        ReaderTurnDirection.PREVIOUS -> 0f
+        ReaderTurnDirection.NEXT -> pageWidth * .9f
+    }
+
+    fun settledX(
+        direction: ReaderTurnDirection,
+        committed: Boolean,
+        pageWidth: Float,
+    ): Float = when (direction) {
+        ReaderTurnDirection.PREVIOUS -> if (committed) pageWidth else -pageWidth
+        ReaderTurnDirection.NEXT -> if (committed) -pageWidth else pageWidth
+    }
+
+    fun cornerY(
+        direction: ReaderTurnDirection,
+        capturedY: Float,
+        pageHeight: Float,
+    ): Float = when (direction) {
+        ReaderTurnDirection.PREVIOUS -> pageHeight
+        ReaderTurnDirection.NEXT -> if (capturedY <= pageHeight / 2f) 0f else pageHeight
+    }
+
+    fun settledY(cornerY: Float, pageHeight: Float): Float =
+        if (cornerY <= pageHeight / 2f) 1f else pageHeight
+
+    fun dragY(
+        direction: ReaderTurnDirection,
+        capturedY: Float,
+        currentY: Float,
+        pageHeight: Float,
+    ): Float {
+        if (pageHeight <= 0f) return currentY
+        if (direction == ReaderTurnDirection.PREVIOUS) return pageHeight
+        return when {
+            capturedY > pageHeight / 3f && capturedY < pageHeight / 2f -> 1f
+            capturedY >= pageHeight / 2f && capturedY < pageHeight * 2f / 3f -> pageHeight
+            else -> currentY
+        }
+    }
+
+    fun programmaticY(
+        direction: ReaderTurnDirection,
+        sourceY: Float,
+        pageHeight: Float,
+    ): Float = when (direction) {
+        ReaderTurnDirection.PREVIOUS -> pageHeight
+        ReaderTurnDirection.NEXT -> if (sourceY > pageHeight / 2f) pageHeight * .9f else 1f
+    }
+}
+
+/** Pure page-curl geometry, ported from the mature SimulationPageDelegate algorithm. */
+object PageCurlGeometry {
+    fun calculate(
+        width: Float,
+        height: Float,
+        inputX: Float,
+        inputY: Float,
+        lockedCorner: CurlPoint? = null,
+    ): PageCurlFrame? {
+        if (width <= 0f || height <= 0f) return null
+        val corner = lockedCorner ?: CurlPoint(
+            if (inputX <= width / 2f) 0f else width,
+            if (inputY <= height / 2f) 0f else height,
+        )
+        var touch = CurlPoint(inputX.coerceIn(.1f, width - .1f), inputY.coerceIn(.1f, height - .1f))
+        fun controls(point: CurlPoint): Pair<CurlPoint, CurlPoint> {
+            val middleX = (point.x + corner.x) / 2f
+            val middleY = (point.y + corner.y) / 2f
+            val dx = (corner.x - middleX).takeUnless { abs(it) < .0001f } ?: .0001f
+            val dy = (corner.y - middleY).takeUnless { abs(it) < .0001f } ?: .1f
+            return CurlPoint(middleX - (corner.y - middleY) * (corner.y - middleY) / dx, corner.y) to
+                CurlPoint(corner.x, middleY - (corner.x - middleX) * (corner.x - middleX) / dy)
+        }
+        var (control1, control2) = controls(touch)
+        var start1 = CurlPoint(control1.x - (corner.x - control1.x) / 2f, corner.y)
+        if (touch.x > 0f && touch.x < width && start1.x !in 0f..width) {
+            if (start1.x < 0f) start1 = CurlPoint(width - start1.x, start1.y)
+            val f1 = abs(corner.x - touch.x).coerceAtLeast(.1f)
+            val f2 = width * f1 / start1.x
+            val adjustedX = abs(corner.x - f2)
+            val f3 = abs(corner.x - adjustedX) * abs(corner.y - touch.y) / f1
+            touch = CurlPoint(adjustedX, abs(corner.y - f3))
+            val controls = controls(touch)
+            control1 = controls.first
+            control2 = controls.second
+            start1 = CurlPoint(control1.x - (corner.x - control1.x) / 2f, corner.y)
+        }
+        val start2 = CurlPoint(corner.x, control2.y - (corner.y - control2.y) / 2f)
+        val end1 = cross(touch, control1, start1, start2) ?: return null
+        val end2 = cross(touch, control2, start1, start2) ?: return null
+        val vertex1 = CurlPoint((start1.x + 2f * control1.x + end1.x) / 4f, (2f * control1.y + start1.y + end1.y) / 4f)
+        val vertex2 = CurlPoint((start2.x + 2f * control2.x + end2.x) / 4f, (2f * control2.y + start2.y + end2.y) / 4f)
+        val distance = hypot((touch.x - corner.x).toDouble(), (touch.y - corner.y).toDouble()).toFloat()
+        val degrees = (atan2((control1.x - corner.x).toDouble(), (control2.y - corner.y).toDouble()) * 180.0 / PI).toFloat()
+        val mirror = mirror(corner, control1, control2)
+        return PageCurlFrame(touch, corner, control1, control2, start1, start2, end1, end2, vertex1, vertex2, degrees, distance, mirror).takeIf(PageCurlFrame::isValid)
+    }
+
+    private fun cross(p1: CurlPoint, p2: CurlPoint, p3: CurlPoint, p4: CurlPoint): CurlPoint? {
+        val dx1 = p2.x - p1.x
+        val dx2 = p4.x - p3.x
+        if (abs(dx1) < .0001f || abs(dx2) < .0001f) return null
+        val a1 = (p2.y - p1.y) / dx1
+        val b1 = p1.y - a1 * p1.x
+        val a2 = (p4.y - p3.y) / dx2
+        val b2 = p3.y - a2 * p3.x
+        if (abs(a1 - a2) < .0001f) return null
+        val x = (b2 - b1) / (a1 - a2)
+        return CurlPoint(x, a1 * x + b1)
+    }
+
+    private fun mirror(corner: CurlPoint, c1: CurlPoint, c2: CurlPoint): CurlMirror {
+        val distance = hypot((corner.x - c1.x).toDouble(), (c2.y - corner.y).toDouble()).toFloat().coerceAtLeast(.0001f)
+        val f8 = (corner.x - c1.x) / distance
+        val f9 = (c2.y - corner.y) / distance
+        val sx = 1f - 2f * f9 * f9
+        val skew = 2f * f8 * f9
+        val sy = 1f - 2f * f8 * f8
+        return CurlMirror(sx, skew, skew, sy, c1.x - (sx * c1.x + skew * c1.y), c1.y - (skew * c1.x + sy * c1.y))
+    }
+}

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

@@ -0,0 +1,27 @@
+package io.legado.app.feature.reader.core.transition
+
+enum class ReaderAutoPageVisualMode {
+    PROGRESSIVE,
+    DISCRETE,
+}
+
+/** Keeps e-ink auto paging free of continuous frame updates, matching the legacy reader. */
+object ReaderAutoPagePolicy {
+    fun visualMode(isEInkMode: Boolean): ReaderAutoPageVisualMode =
+        if (isEInkMode) ReaderAutoPageVisualMode.DISCRETE else ReaderAutoPageVisualMode.PROGRESSIVE
+
+    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)
+}

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

@@ -0,0 +1,213 @@
+package io.legado.app.feature.reader.core.transition
+
+import kotlin.math.abs
+
+enum class ReaderTransitionMode {
+    COVER,
+    SLIDE,
+    SIMULATION,
+    SCROLL,
+    FADE,
+    NONE;
+
+    companion object {
+        fun fromPageAnim(value: Int): ReaderTransitionMode = when (value) {
+            0 -> COVER
+            1 -> SLIDE
+            2 -> SIMULATION
+            3 -> SCROLL
+            4 -> FADE
+            else -> NONE
+        }
+    }
+}
+
+enum class ReaderTurnDirection { PREVIOUS, NEXT }
+
+object ReaderCoverShadowPolicy {
+    const val colorArgb: Int = 0x30111111
+    const val widthDp: Float = 36f
+
+    /** The cover shadow fades rightward from the moving page's trailing edge. */
+    fun edgePx(direction: ReaderTurnDirection, displayOffsetPx: Float, pageWidthPx: Float): Float =
+        when (direction) {
+            ReaderTurnDirection.PREVIOUS -> displayOffsetPx
+            ReaderTurnDirection.NEXT -> pageWidthPx + displayOffsetPx
+        }
+}
+
+object ReaderProgrammaticTurnPolicy {
+    /** Legacy PageDelegate.keyTurnPage ignores keys while a page animation is running. */
+    fun shouldAccept(animationRunning: Boolean): Boolean = !animationRunning
+}
+
+data class ReaderPageTransition(
+    val direction: ReaderTurnDirection? = null,
+    val offsetPx: Float = 0f,
+    val pageExtentPx: Float = 0f,
+    val dragging: Boolean = false,
+) {
+    val progress: Float
+        get() = if (pageExtentPx <= 0f) 0f else (abs(offsetPx) / pageExtentPx).coerceIn(0f, 1f)
+}
+
+data class ReaderTransitionDecision(
+    val targetOffsetPx: Float,
+    val commit: Boolean,
+)
+
+/** Direction is captured at touch-slop, while visible displacement starts at that capture point. */
+data class ReaderHorizontalDrag(
+    val originPx: Float,
+    val direction: ReaderTurnDirection,
+) {
+    fun transition(
+        totalDeltaPx: Float,
+        pageExtentPx: Float,
+        hasPrevious: Boolean,
+        hasNext: Boolean,
+    ): ReaderPageTransition = ReaderPageTransitionPolicy.drag(
+        deltaPx = totalDeltaPx - originPx,
+        pageExtentPx = pageExtentPx,
+        hasPrevious = hasPrevious,
+        hasNext = hasNext,
+        lockedDirection = direction,
+    )
+
+    companion object {
+        fun capture(totalDeltaPx: Float): ReaderHorizontalDrag? = when {
+            totalDeltaPx > 0f -> ReaderHorizontalDrag(totalDeltaPx, ReaderTurnDirection.PREVIOUS)
+            totalDeltaPx < 0f -> ReaderHorizontalDrag(totalDeltaPx, ReaderTurnDirection.NEXT)
+            else -> null
+        }
+
+    }
+}
+
+object ReaderPageTransitionPolicy {
+    fun settleDurationMillis(
+        mode: ReaderTransitionMode,
+        currentOffsetPx: Float,
+        targetOffsetPx: Float,
+        pageExtentPx: Float,
+        baseDurationMillis: Int = 300,
+    ): Int {
+        if (pageExtentPx <= 0f || baseDurationMillis <= 0 || currentOffsetPx == targetOffsetPx) return 0
+        // FadePageDelegate animates its normalized alpha for the full configured duration.
+        // Horizontal/curl delegates scale duration by their remaining scroll distance.
+        return if (mode == ReaderTransitionMode.FADE) {
+            baseDurationMillis
+        } else {
+            settleDurationMillis(currentOffsetPx, targetOffsetPx, pageExtentPx, baseDurationMillis)
+        }
+    }
+
+    fun settleDurationMillis(
+        currentOffsetPx: Float,
+        targetOffsetPx: Float,
+        pageExtentPx: Float,
+        baseDurationMillis: Int = 300,
+    ): Int {
+        if (pageExtentPx <= 0f || baseDurationMillis <= 0) return 0
+        return (baseDurationMillis * abs(targetOffsetPx - currentOffsetPx) / pageExtentPx)
+            .toInt().coerceIn(0, baseDurationMillis)
+    }
+
+    fun drag(
+        deltaPx: Float,
+        pageExtentPx: Float,
+        hasPrevious: Boolean,
+        hasNext: Boolean,
+        lockedDirection: ReaderTurnDirection? = null,
+    ): ReaderPageTransition {
+        if (pageExtentPx <= 0f || (deltaPx == 0f && lockedDirection == null)) return ReaderPageTransition(pageExtentPx = pageExtentPx)
+        val direction = lockedDirection ?: if (deltaPx > 0f) ReaderTurnDirection.PREVIOUS else ReaderTurnDirection.NEXT
+        val available = if (direction == ReaderTurnDirection.PREVIOUS) hasPrevious else hasNext
+        return ReaderPageTransition(
+            direction = direction,
+            offsetPx = if (!available) 0f else when (direction) {
+                ReaderTurnDirection.PREVIOUS -> deltaPx.coerceIn(0f, pageExtentPx)
+                ReaderTurnDirection.NEXT -> deltaPx.coerceIn(-pageExtentPx, 0f)
+            },
+            pageExtentPx = pageExtentPx,
+            dragging = available,
+        )
+    }
+
+    fun release(
+        transition: ReaderPageTransition,
+        velocityPxPerSecond: Float,
+        commitProgress: Float = 0.35f,
+        flingVelocityPxPerSecond: Float = 900f,
+        cancelled: Boolean = false,
+        lastDragDeltaPx: Float? = null,
+    ): ReaderTransitionDecision {
+        val direction = transition.direction
+            ?: return ReaderTransitionDecision(targetOffsetPx = 0f, commit = false)
+        val velocityCommits = when (direction) {
+            ReaderTurnDirection.PREVIOUS -> velocityPxPerSecond >= flingVelocityPxPerSecond
+            ReaderTurnDirection.NEXT -> velocityPxPerSecond <= -flingVelocityPxPerSecond
+        }
+        // Horizontal View delegates cancel on the final reversal, regardless of distance.
+        val movingForward = lastDragDeltaPx?.takeIf { it != 0f }?.let {
+            if (direction == ReaderTurnDirection.NEXT) it < 0f else it > 0f
+        }
+        val commit = !cancelled && transition.dragging &&
+            (movingForward ?: (transition.progress >= commitProgress || velocityCommits))
+        val target = if (!commit) 0f else when (direction) {
+            ReaderTurnDirection.PREVIOUS -> transition.pageExtentPx
+            ReaderTurnDirection.NEXT -> -transition.pageExtentPx
+        }
+        return ReaderTransitionDecision(targetOffsetPx = target, commit = commit)
+    }
+}
+
+data class ReaderPageTransform(
+    val translationX: Float = 0f,
+    val translationY: Float = 0f,
+    val alpha: Float = 1f,
+)
+
+data class ReaderTransitionTransforms(
+    val previous: ReaderPageTransform? = null,
+    val current: ReaderPageTransform = ReaderPageTransform(),
+    val next: ReaderPageTransform? = null,
+    val currentOnTop: Boolean = false,
+)
+
+fun ReaderPageTransition.transforms(mode: ReaderTransitionMode): ReaderTransitionTransforms {
+    val width = pageExtentPx
+    return when (direction) {
+        ReaderTurnDirection.PREVIOUS -> when (mode) {
+            ReaderTransitionMode.FADE -> ReaderTransitionTransforms(
+                previous = ReaderPageTransform(alpha = progress),
+            )
+            ReaderTransitionMode.COVER -> ReaderTransitionTransforms(
+                previous = ReaderPageTransform(translationX = -width + offsetPx),
+            )
+            ReaderTransitionMode.SLIDE -> ReaderTransitionTransforms(
+                previous = ReaderPageTransform(translationX = -width + offsetPx),
+                current = ReaderPageTransform(translationX = offsetPx),
+            )
+            else -> ReaderTransitionTransforms()
+        }
+        ReaderTurnDirection.NEXT -> when (mode) {
+            ReaderTransitionMode.FADE -> ReaderTransitionTransforms(
+                next = ReaderPageTransform(alpha = progress),
+            )
+            ReaderTransitionMode.COVER -> ReaderTransitionTransforms(
+                current = ReaderPageTransform(translationX = offsetPx),
+                next = ReaderPageTransform(),
+                currentOnTop = true,
+            )
+            ReaderTransitionMode.SLIDE -> ReaderTransitionTransforms(
+                current = ReaderPageTransform(translationX = offsetPx),
+                next = ReaderPageTransform(translationX = width + offsetPx),
+                // SlidePageDelegate draws the destination first, then the current page.
+                currentOnTop = true,
+            )
+            else -> ReaderTransitionTransforms()
+        }
+        null -> ReaderTransitionTransforms()
+    }
+}

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

@@ -0,0 +1,87 @@
+package io.legado.app.feature.reader.core.transition
+
+import io.legado.app.feature.reader.core.model.ReaderPageId
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPage
+
+enum class ReaderScrollCrossing { PREVIOUS, NEXT }
+
+data class ReaderScrollResult(
+    val offsetPx: Float,
+    val crossing: ReaderScrollCrossing? = null,
+    val hitBoundary: Boolean = false,
+)
+
+/** One-frame continuous-scroll reducer. Page replacement is performed by the host after crossing. */
+object ReaderScrollPolicy {
+    /** Do not reduce another frame against the old page after a crossing requested replacement. */
+    fun canApplyDelta(pendingPageTarget: ReaderPageId?): Boolean = pendingPageTarget == null
+
+    /**
+     * Click paging keeps one visible text row for context, matching ScrollPageDelegate.
+     * Non-inline image pages and empty pages move by one full viewport.
+     */
+    fun pageStep(
+        page: ReaderPage,
+        offsetPx: Float,
+        direction: ReaderTurnDirection,
+    ): Float {
+        val viewport = (page.contentBottomPx - page.contentTopPx).coerceAtLeast(1f)
+        val visible = page.elements.filter { element ->
+            element.bounds.bottom + offsetPx > page.contentTopPx &&
+                element.bounds.top + offsetPx < page.contentBottomPx
+        }
+        val text = visible.filterIsInstance<ReaderElement.Text>()
+        if (text.isEmpty() ||
+            (!page.inlineImagesPreserveScrollLine && visible.any { it is ReaderElement.Image })
+        ) return if (direction == ReaderTurnDirection.PREVIOUS) viewport else -viewport
+
+        val distance = when (direction) {
+            ReaderTurnDirection.NEXT ->
+                text.maxOf { it.bounds.top } + offsetPx - page.contentTopPx
+            ReaderTurnDirection.PREVIOUS ->
+                viewport - (text.minOf { it.bounds.bottom } + offsetPx - page.contentTopPx)
+        }.coerceIn(0f, viewport)
+        val effective = distance.takeIf { it > 0f } ?: viewport
+        return if (direction == ReaderTurnDirection.PREVIOUS) effective else -effective
+    }
+
+    fun offsetAfterPageChange(
+        offsetPx: Float,
+        carriedTarget: ReaderPageId?,
+        currentPage: ReaderPageId,
+    ): Float = if (carriedTarget == currentPage) offsetPx else 0f
+
+    fun apply(
+        offsetPx: Float,
+        deltaPx: Float,
+        previousExtentPx: Float,
+        currentExtentPx: Float,
+        viewportExtentPx: Float,
+        hasPrevious: Boolean,
+        hasNext: Boolean,
+    ): ReaderScrollResult {
+        if (currentExtentPx <= 0f) return ReaderScrollResult(0f)
+        val next = offsetPx + deltaPx
+        if (next > 0f) {
+            return if (hasPrevious && previousExtentPx > 0f) {
+                ReaderScrollResult(next - previousExtentPx, ReaderScrollCrossing.PREVIOUS)
+            } else ReaderScrollResult(0f, hitBoundary = true)
+        }
+        if (!hasNext && next < 0f && next + currentExtentPx < viewportExtentPx) {
+            return ReaderScrollResult(
+                offsetPx = minOf(0f, viewportExtentPx - currentExtentPx),
+                hitBoundary = true,
+            )
+        }
+        if (next < -currentExtentPx) {
+            return if (hasNext) {
+                ReaderScrollResult(next + currentExtentPx, ReaderScrollCrossing.NEXT)
+            } else {
+                val bottom = minOf(0f, viewportExtentPx - currentExtentPx)
+                ReaderScrollResult(bottom, hitBoundary = true)
+            }
+        }
+        return ReaderScrollResult(next)
+    }
+}

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

@@ -0,0 +1,10 @@
+package io.legado.app.feature.reader.core.transition
+
+/** Viewport-owned layers stay fixed while continuous-scroll pages move underneath them. */
+object ReaderViewportLayerPolicy {
+    fun usesFixedBackground(mode: ReaderTransitionMode): Boolean =
+        mode == ReaderTransitionMode.SCROLL
+
+    fun usesFixedPageChrome(mode: ReaderTransitionMode): Boolean =
+        mode == ReaderTransitionMode.SCROLL
+}

+ 230 - 0
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -0,0 +1,230 @@
+package io.legado.app.feature.reader.legacy
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.feature.reader.core.layout.ReaderChapterBlockMeasurer
+import io.legado.app.feature.reader.core.layout.ReaderChapterMeasureResult
+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.ReaderTextAlignment
+import io.legado.app.feature.reader.core.layout.ReaderTextShaperFactory
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.source.ReaderChapterSource
+import io.legado.app.feature.reader.platform.AndroidReaderTextShaper
+import io.legado.app.feature.reader.platform.AndroidReaderHtmlSourceResolver
+import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
+import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
+import io.legado.app.help.book.BookContent
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.model.ImageProvider
+import kotlinx.coroutines.CancellationException
+import splitties.init.appCtx
+
+sealed interface LegacyReaderChapterPaginationResult {
+    data class Success(val pages: List<ReaderPage>) : LegacyReaderChapterPaginationResult
+    data class Unsupported(val reason: String) : LegacyReaderChapterPaginationResult
+}
+
+data class LegacyReaderPaginationBatch(
+    val pages: List<ReaderPage>,
+    val unsupportedChapters: Map<Int, String>,
+    val hasCurrentChapter: Boolean,
+)
+
+/** Every chapter-side input that can change measured page geometry or image resolution. */
+data class LegacyReaderChapterLayoutIdentity(
+    val chapterIndex: Int,
+    val chapterUrl: String,
+    val chapterBaseUrl: String,
+    val displayTitle: String,
+    val isVolume: Boolean,
+    val contentHash: Int,
+    val contentProcessesHash: Int,
+    val sourceHash: Int,
+    val bookUrl: String,
+    val bookOrigin: String,
+    val bookSourceHash: Int,
+)
+
+/**
+ * Keeps a malformed adjacent chapter from cancelling pagination of the current chapter.
+ * Cancellation is control flow and must still stop the whole generation.
+ */
+suspend fun paginateLegacyReaderChapterSafely(
+    paginate: suspend () -> LegacyReaderChapterPaginationResult,
+): LegacyReaderChapterPaginationResult = try {
+    paginate()
+} catch (error: CancellationException) {
+    throw error
+} catch (error: Exception) {
+    LegacyReaderChapterPaginationResult.Unsupported(
+        "exception:${error::class.simpleName ?: "unknown"}",
+    )
+}
+
+fun collectLegacyReaderPaginationBatch(
+    currentChapterIndex: Int,
+    results: List<Pair<Int, LegacyReaderChapterPaginationResult>>,
+): LegacyReaderPaginationBatch {
+    val pages = mutableListOf<ReaderPage>()
+    val unsupported = linkedMapOf<Int, String>()
+    var hasCurrentChapter = false
+    results.forEach { (chapterIndex, result) ->
+        when (result) {
+            is LegacyReaderChapterPaginationResult.Success -> {
+                pages += result.pages
+                if (chapterIndex == currentChapterIndex) hasCurrentChapter = true
+            }
+            is LegacyReaderChapterPaginationResult.Unsupported -> unsupported[chapterIndex] = result.reason
+        }
+    }
+    return LegacyReaderPaginationBatch(
+        pages = pages,
+        unsupportedChapters = unsupported,
+        hasCurrentChapter = hasCurrentChapter,
+    )
+}
+
+fun LegacyReaderPaginationBatch.failureReasonFor(chapterIndex: Int): String? =
+    unsupportedChapters[chapterIndex]
+
+/**
+ * Temporary Android configuration adapter. Output pages belong entirely to the new reader core;
+ * this bridge can be deleted once reader settings and chapter source have dedicated gateways.
+ */
+object LegacyReaderChapterPaginator {
+    suspend fun paginate(
+        book: Book,
+        bookSource: BookSource?,
+        chapter: BookChapter,
+        displayTitle: String,
+        content: BookContent,
+        source: ReaderChapterSource,
+        revision: Long,
+        viewportWidthPx: Int,
+        viewportHeightPx: Int,
+        contentPaddingLeftPx: Int = 0,
+        contentPaddingTopPx: Int = 0,
+        contentPaddingRightPx: Int = 0,
+        contentPaddingBottomPx: Int = 0,
+        paginationStyle: ReaderAndroidPaginationStyle,
+        highlightRules: List<HighlightRule>,
+    ): LegacyReaderChapterPaginationResult {
+        if (viewportWidthPx <= 0 || viewportHeightPx <= 0) {
+            return LegacyReaderChapterPaginationResult.Unsupported("viewport")
+        }
+        val imageLayoutMode = when (book.getImageStyle()?.uppercase()) {
+            Book.imgStyleText -> ReaderImageLayoutMode.INLINE
+            Book.imgStyleFull -> ReaderImageLayoutMode.FULL_WIDTH
+            Book.imgStyleSingle -> ReaderImageLayoutMode.SINGLE_PAGE
+            else -> ReaderImageLayoutMode.AUTO
+        }
+        val singleImage = imageLayoutMode == ReaderImageLayoutMode.SINGLE_PAGE
+        val layoutSource = source.withTitleVisibility(
+            ReadBookConfig.titleMode != 2 || chapter.isVolume || content.textList.isEmpty(),
+            paginationStyle.titleSegmentation,
+        )
+        val styleRanges = LegacyReaderStyleRangeMapper.map(
+            source = layoutSource,
+            rules = highlightRules,
+            processes = content.effectiveContentProcesses,
+        )
+        val bodyPaint = paginationStyle.bodyPaint
+        val titlePaint = paginationStyle.titlePaint
+        val bodyStyle = paginationStyle.bodyStyle
+        val titleStyle = paginationStyle.titleStyle
+        val measurer = ReaderChapterBlockMeasurer(
+            bodyShaper = AndroidReaderTextShaper(bodyPaint),
+            titleShaper = AndroidReaderTextShaper(titlePaint),
+            imageDimensionsResolver = { imageSource ->
+                try {
+                    ImageProvider.getImageSize(book, imageSource, bookSource).let {
+                        ReaderImageDimensions(it.width.toFloat(), it.height.toFloat())
+                    }
+                } catch (error: CancellationException) {
+                    throw error
+                } catch (_: Exception) {
+                    null
+                }
+            },
+            textShaperFactory = ReaderTextShaperFactory { textStyle ->
+                AndroidReaderTextShaper(ReaderAndroidPaintFactory.createTextPaint(textStyle))
+            },
+            htmlSourceResolver = AndroidReaderHtmlSourceResolver(
+                baseTextSizePx = bodyPaint.textSize,
+                density = appCtx.resources.displayMetrics.density,
+            ),
+            imageOptionsResolver = LegacyReaderImageOptionsResolver,
+        )
+        val measured = measurer.measure(
+            layoutSource,
+            ReaderChapterMeasureStyle(
+                bodyStyle = bodyStyle,
+                titleStyle = titleStyle,
+                bodyIndentCharacters = ReadBookConfig.paragraphIndent.length,
+                bodyIndentText = ReadBookConfig.paragraphIndent,
+                bodyAlignment = if (ReadBookConfig.textFullJustify) ReaderTextAlignment.JUSTIFY else ReaderTextAlignment.START,
+                titleAlignment = if (ReadBookConfig.isMiddleTitle || chapter.isVolume ||
+                    content.textList.isEmpty() || singleImage
+                ) ReaderTextAlignment.CENTER else ReaderTextAlignment.START,
+                imagePageBreakBefore = singleImage,
+                imagePageBreakAfter = singleImage,
+                imageLayoutMode = imageLayoutMode,
+                imageAvailableWidthPx = (
+                    viewportWidthPx / paginationStyle.columnCount(viewportWidthPx, viewportHeightPx) -
+                        paginationStyle.paddingLeftPx - paginationStyle.paddingRightPx -
+                        contentPaddingLeftPx - contentPaddingRightPx
+                    ).coerceAtLeast(0).toFloat(),
+                bodyLineHeightPx = paginationStyle.bodyTextHeightPx,
+                bodyBaselineOffsetPx = paginationStyle.bodyBaselineOffsetPx,
+                titleLineHeightPx = paginationStyle.titleTextHeightPx,
+                titleBaselineOffsetPx = paginationStyle.titleBaselineOffsetPx,
+                bodyLineSpacingMultiplier = paginationStyle.lineSpacingExtra,
+                titleLineSpacingMultiplier = paginationStyle.titleLineSpacingExtra,
+                letterSpacingEm = bodyPaint.letterSpacing,
+                styleRanges = styleRanges,
+            ),
+        )
+        if (measured is ReaderChapterMeasureResult.Unsupported) {
+            return LegacyReaderChapterPaginationResult.Unsupported(measured.reason)
+        }
+        val blocks = (measured as ReaderChapterMeasureResult.Success).blocks
+        val 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,
+                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,
+                titleParagraphSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.paragraphSpacing / 10f,
+                titleSegmentSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.titleLineSpacingSub,
+                letterSpacingPx = bodyPaint.letterSpacing * bodyPaint.textSize,
+                revision = revision,
+            ),
+        )
+        return LegacyReaderChapterPaginationResult.Success(pages)
+    }
+}

+ 40 - 0
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderImageOptionsResolver.kt

@@ -0,0 +1,40 @@
+package io.legado.app.feature.reader.legacy
+
+import io.legado.app.feature.reader.core.layout.ReaderImageLayoutMode
+import io.legado.app.feature.reader.core.layout.ReaderImageOptions
+import io.legado.app.feature.reader.core.layout.ReaderImageOptionsResolver
+import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
+import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+
+/** Keeps source-URL JSON parsing at the legacy input boundary; the Canvas core receives typed options. */
+object LegacyReaderImageOptionsResolver : ReaderImageOptionsResolver {
+    override fun resolve(source: String): ReaderImageOptions? {
+        val separator = paramPattern.find(source) ?: return null
+        val values = GSON.fromJsonObject<Map<String, String>>(
+            source.substring(separator.range.last + 1),
+        ).getOrNull() ?: return null
+        val style = values["style"]?.uppercase()
+        val width = values["width"]
+        return ReaderImageOptions(
+            layoutMode = when (style) {
+                null -> null
+                "TEXT" -> ReaderImageLayoutMode.INLINE
+                "FULL" -> ReaderImageLayoutMode.FULL_WIDTH
+                "SINGLE" -> ReaderImageLayoutMode.SINGLE_PAGE
+                else -> ReaderImageLayoutMode.STANDALONE
+            },
+            requestedWidthPx = width?.takeUnless { it.endsWith('%') }?.toFloatOrNull()
+                ?.takeIf { it > 0f },
+            requestedWidthFraction = width?.takeIf { it.endsWith('%') }
+                ?.dropLast(1)?.toFloatOrNull()?.div(100f)?.takeIf { it > 0f },
+            horizontalAlignment = when (style) {
+                "LEFT" -> ReaderTextAlignment.START
+                "RIGHT" -> ReaderTextAlignment.END
+                else -> null
+            },
+            action = values["click"]?.takeIf(String::isNotBlank),
+        )
+    }
+}

+ 262 - 0
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderPageDecorationFactory.kt

@@ -0,0 +1,262 @@
+package io.legado.app.feature.reader.legacy
+
+import androidx.core.content.ContextCompat
+import android.graphics.Paint
+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.domain.gateway.ReadSettingsGateway
+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.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.resolveReaderTipColor
+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 splitties.init.appCtx
+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()
+    fun headerExtentPx(): Float = if (headerVisible()) {
+        tipRowExtentPx(
+            fontSizePx = ReadBookConfig.headerFontSize.toFloat().spToPx(),
+            fontPath = ReadBookConfig.headerFont,
+            paddingTopPx = ReadBookConfig.headerPaddingTop.dpToPx().toFloat(),
+            paddingBottomPx = ReadBookConfig.headerPaddingBottom.dpToPx().toFloat(),
+            dividerVisible = ReadBookConfig.showHeaderLine,
+        )
+    } else 0f
+
+    fun footerExtentPx(): Float = if (footerVisible()) {
+        tipRowExtentPx(
+            fontSizePx = (if (ReadBookConfig.applyHeaderStyle) {
+                ReadBookConfig.headerFontSize
+            } else {
+                ReadBookConfig.footerFontSize
+            }).toFloat().spToPx(),
+            fontPath = if (ReadBookConfig.applyHeaderStyle) ReadBookConfig.headerFont else ReadBookConfig.footerFont,
+            paddingTopPx = ReadBookConfig.footerPaddingTop.dpToPx().toFloat(),
+            paddingBottomPx = ReadBookConfig.footerPaddingBottom.dpToPx().toFloat(),
+            dividerVisible = ReadBookConfig.showFooterLine,
+        )
+    } else 0f
+
+    private fun tipRowExtentPx(
+        fontSizePx: Float,
+        fontPath: 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)
+        }.fontMetrics
+        return ReaderTipRowLayout.extent(
+            paddingTopPx = paddingTopPx,
+            fontTopPx = metrics.top,
+            fontBottomPx = metrics.bottom,
+            paddingBottomPx = paddingBottomPx,
+            dividerExtentPx = if (dividerVisible) 0.5f.dpToPx() else 0f,
+        )
+    }
+
+    fun create(
+        page: ReaderPage,
+        chapterPageCount: Int,
+        time: String,
+        batteryPercent: Int,
+        hasBookmark: Boolean = false,
+        contentPaddingLeftPx: Int = 0,
+        contentPaddingTopPx: Int = 0,
+        contentPaddingRightPx: Int = 0,
+        contentPaddingBottomPx: Int = 0,
+    ): ReaderPageDecoration {
+        val settings = readSettings.currentSettings
+        val wholeBook = ReadBook.getWholeBookPageState(page.id.chapterIndex, page.id.pageIndex)
+        val context = ReaderTipValueContext(
+            bookName = ReadBook.book?.name.orEmpty(),
+            chapterTitle = page.chapterTitle,
+            time = time.ifBlank { timeFormat.format(Date()) },
+            batteryPercent = batteryPercent.coerceIn(0, 100),
+            chapterIndex = page.id.chapterIndex,
+            chapterCount = ReadBook.chapterSize.coerceAtLeast(1),
+            pageIndex = page.id.pageIndex,
+            pageCount = chapterPageCount.coerceAtLeast(1),
+            readProgress = readProgress(page.id.chapterIndex, page.id.pageIndex, chapterPageCount),
+            wholeBookPageIndex = wholeBook?.currentPage,
+            wholeBookPageCount = wholeBook?.totalPages,
+        )
+        val dividerColor = when (val configured = ReadBookConfig.tipDividerColor) {
+            -1 -> ContextCompat.getColor(appCtx, R.color.divider)
+            0 -> ReadBookConfig.textColor
+            else -> configured
+        }
+        return ReaderPageDecoration(
+            bookmarkBadge = ReaderBookmarkBadge.create(
+                hasBookmark = hasBookmark,
+                isScroll = ReadBook.pageAnim() == 3,
+                pageWidthPx = page.widthPx,
+                contentTopPx = page.contentTopPx,
+                contentRightPaddingPx = ReadBookConfig.paddingRight.dpToPx(),
+                density = appCtx.resources.displayMetrics.density,
+                sizeDp = settings.bookmarkBadgeSize,
+                imageSource = settings.bookmarkBadgeImage,
+                imageVersion = settings.bookmarkBadgeImage.takeIf { hasBookmark && it.isNotBlank() }
+                    ?.let { File(it).let { file -> "${file.lastModified()}:${file.length()}" } }.orEmpty(),
+            ),
+            header = ReaderTipRow(
+                visible = headerVisible(),
+                tips = tips(
+                    context,
+                    ReadBookConfig.tipHeaderLeft to ReadBookConfig.customTipHeaderLeft,
+                    ReadBookConfig.tipHeaderMiddle to ReadBookConfig.customTipHeaderMiddle,
+                    ReadBookConfig.tipHeaderRight to ReadBookConfig.customTipHeaderRight,
+                ),
+                colorArgb = resolveReaderTipColor(
+                    ReadBookConfig.resolvedTipHeaderColor,
+                    ReadBookConfig.textColor,
+                ),
+                fontSizePx = ReadBookConfig.headerFontSize.toFloat().spToPx(),
+                fontPath = 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 },
+            ),
+            footer = ReaderTipRow(
+                visible = footerVisible(),
+                tips = tips(
+                    context,
+                    ReadBookConfig.tipFooterLeft to ReadBookConfig.customTipFooterLeft,
+                    ReadBookConfig.tipFooterMiddle to ReadBookConfig.customTipFooterMiddle,
+                    ReadBookConfig.tipFooterRight to ReadBookConfig.customTipFooterRight,
+                ),
+                colorArgb = resolveReaderTipColor(
+                    ReadBookConfig.resolvedTipFooterColor,
+                    ReadBookConfig.textColor,
+                ),
+                fontSizePx = (if (ReadBookConfig.applyHeaderStyle) {
+                    ReadBookConfig.headerFontSize
+                } else {
+                    ReadBookConfig.footerFontSize
+                }).toFloat().spToPx(),
+                fontPath = 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 },
+            ),
+        )
+    }
+
+    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 }
+
+    private fun tip(
+        config: Pair<Int, String>,
+        context: ReaderTipValueContext,
+        alignment: ReaderTipAlignment,
+    ): ReaderPageTip {
+        val visual = when (config.first) {
+            ReadTipType.tipBattery -> ReaderTipVisual.BATTERY_OUTER
+            ReadTipType.tipBatteryInside,
+            ReadTipType.tipTimeBattery -> ReaderTipVisual.BATTERY_INNER
+            ReadTipType.tipBatteryIcon -> ReaderTipVisual.BATTERY_ICON
+            ReadTipType.tipBatteryClassic,
+            ReadTipType.tipTimeBatteryClassic -> ReaderTipVisual.BATTERY_CLASSIC
+            ReadTipType.tipChapterTitleArrow,
+            ReadTipType.tipChapterTitleArrowClassic -> ReaderTipVisual.ARROW
+            else -> ReaderTipVisual.TEXT
+        }
+        val text = when (config.first) {
+            ReadTipType.tipBattery,
+            ReadTipType.tipBatteryInside,
+            ReadTipType.tipBatteryIcon,
+            ReadTipType.tipBatteryClassic -> ""
+            ReadTipType.tipTimeBattery,
+            ReadTipType.tipTimeBatteryClassic -> context.time
+            else -> value(config, context)
+        }
+        return ReaderPageTip(
+            text = text,
+            alignment = alignment,
+            visual = visual,
+            batteryPercent = context.batteryPercent,
+        )
+    }
+
+    private fun value(config: Pair<Int, String>, context: ReaderTipValueContext): String =
+        ReaderTipValueFormatter.format(type(config.first), context, config.second)
+
+    private fun type(type: Int): ReaderTipValueType = when (type) {
+        ReadTipType.tipNone -> ReaderTipValueType.NONE
+        ReadTipType.tipChapterTitle -> ReaderTipValueType.CHAPTER_TITLE
+        ReadTipType.tipTime -> ReaderTipValueType.TIME
+        ReadTipType.tipBattery,
+        ReadTipType.tipBatteryPercentage,
+        ReadTipType.tipBatteryInside,
+        ReadTipType.tipBatteryIcon,
+        ReadTipType.tipBatteryClassic -> ReaderTipValueType.BATTERY
+        ReadTipType.tipPage -> ReaderTipValueType.PAGE
+        ReadTipType.tipTotalProgress -> ReaderTipValueType.TOTAL_PROGRESS
+        ReadTipType.tipPageAndTotal -> ReaderTipValueType.PAGE_AND_TOTAL
+        ReadTipType.tipBookName -> ReaderTipValueType.BOOK_NAME
+        ReadTipType.tipTimeBattery,
+        ReadTipType.tipTimeBatteryPercentage,
+        ReadTipType.tipTimeBatteryClassic -> ReaderTipValueType.TIME_BATTERY
+        ReadTipType.tipTotalProgress1 -> ReaderTipValueType.CHAPTER_INDEX_AND_TOTAL
+        ReadTipType.tipChapterTitleArrow,
+        ReadTipType.tipChapterTitleArrowClassic -> ReaderTipValueType.CHAPTER_TITLE_ARROW
+        ReadTipType.tipCustom -> ReaderTipValueType.CUSTOM
+        ReadTipType.tipWholeBookPage -> ReaderTipValueType.WHOLE_BOOK_PAGE
+        ReadTipType.tipWholeBookPageAndProgress -> ReaderTipValueType.WHOLE_BOOK_PAGE_AND_PROGRESS
+        else -> ReaderTipValueType.NONE
+    }
+
+    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),
+        )
+        if (progress == "100.0%" && (chapterIndex + 1 != chapterCount || pageIndex + 1 != pageCount)) {
+            progress = "99.9%"
+        }
+        return progress
+    }
+
+    private fun headerVisible(): Boolean = when (ReadBookConfig.headerMode) {
+        1 -> true
+        2 -> false
+        else -> ReadBookConfig.hideStatusBar
+    }
+
+    private fun footerVisible(): Boolean = ReadBookConfig.footerMode != 1
+}

+ 118 - 0
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderPaginationStyleFactory.kt

@@ -0,0 +1,118 @@
+package io.legado.app.feature.reader.legacy
+
+import android.os.Build
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import io.legado.app.feature.reader.core.model.ReaderTextShadow
+import io.legado.app.feature.reader.core.model.ReaderEmphasisUnderline
+import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
+import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
+import io.legado.app.feature.reader.core.source.ReaderTitleSegmentation
+import io.legado.app.feature.reader.core.layout.ReaderColumnMode
+import io.legado.app.feature.reader.core.layout.ReaderPageUnderline
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.help.book.isImage
+import io.legado.app.model.ReadBook
+import io.legado.app.utils.isPad
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.utils.dpToPx
+import io.legado.app.utils.spToPx
+import io.legado.app.utils.textHeight
+import splitties.init.appCtx
+
+/** Builds the direct renderer's shaping snapshot from persisted reader settings. */
+object LegacyReaderPaginationStyleFactory : KoinComponent {
+    private val readSettings: ReadSettingsGateway by inject()
+    private val themeSettings: ThemeSettingsGateway by inject()
+    fun create(): ReaderAndroidPaginationStyle {
+        val settings = readSettings.currentSettings
+        val isEInkMode = themeSettings.currentSettings.appTheme == "4"
+        val bodyStyle = ReaderTextStyle(
+            colorArgb = ReadBookConfig.textColor,
+            fontSizePx = ReadBookConfig.textSize.toFloat().spToPx(),
+            fontPath = ReadBookConfig.textFont,
+            fontWeight = resolveWeight(ReadBookConfig.textBold),
+            italic = ReadBookConfig.textItalic,
+            shadow = if (ReadBookConfig.textShadow) ReaderTextShadow(
+                colorArgb = ReadBookConfig.textShadowColor,
+                radiusPx = ReadBookConfig.shadowRadius,
+                dxPx = ReadBookConfig.shadowDx,
+                dyPx = ReadBookConfig.shadowDy,
+            ) else null,
+            fontFamily = when (settings.systemTypefaces) {
+                1 -> "serif"
+                2 -> "monospace"
+                else -> "sans-serif"
+            },
+            linearText = Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q && settings.optimizeRender,
+        )
+        val titleStyle = bodyStyle.copy(
+            colorArgb = ReadBookConfig.resolvedTitleColor.takeIf { it != 0 } ?: bodyStyle.colorArgb,
+            fontSizePx = ReadBookConfig.titleSize.toFloat().spToPx(),
+            fontPath = ReadBookConfig.titleFont.takeIf(String::isNotBlank) ?: bodyStyle.fontPath,
+            fontWeight = resolveWeight(ReadBookConfig.titleBold),
+        )
+        val letterSpacing = ReadBookConfig.letterSpacing
+        val bodyPaint = ReaderAndroidPaintFactory.createTextPaint(bodyStyle).apply { this.letterSpacing = letterSpacing }
+        val titlePaint = ReaderAndroidPaintFactory.createTextPaint(titleStyle).apply { this.letterSpacing = letterSpacing }
+        return ReaderAndroidPaginationStyle(
+            columnMode = ReaderColumnMode.fromPreference(settings.doubleHorizontalPage),
+            isTablet = appCtx.isPad,
+            isScroll = ReadBook.pageAnim() == 3,
+            textBottomJustify = settings.textBottomJustify,
+            pageUnderline = ReadBookConfig.underline
+                .takeIf { it && ReadBook.book?.isImage != true }
+                ?.let {
+                    ReaderPageUnderline(
+                        colorArgb = ReadBookConfig.durConfig.curUnderlineColor(),
+                        widthPx = ReadBookConfig.underlineHeight.toFloat(),
+                        offsetPx = (ReadBookConfig.underlinePadding - 10).dpToPx().toFloat(),
+                        extendToColumn = ReadBookConfig.underlineExtend,
+                        dashed = ReadBookConfig.dottedLine && !isEInkMode,
+                        dashOnPx = ReadBookConfig.durConfig.dottedBase,
+                        dashOffPx = ReadBookConfig.durConfig.dottedRatio,
+                    )
+                },
+            emphasisUnderlineStyle = settings.useUnderline.takeIf { it }?.let {
+                ReaderEmphasisUnderline(
+                    colorArgb = ReadBookConfig.textColor,
+                    widthPx = ReadBookConfig.underlineHeight.toFloat(),
+                    bottomOffsetPx = 1.dpToPx().toFloat(),
+                )
+            },
+            bodyStyle = bodyStyle,
+            titleStyle = titleStyle,
+            bodyPaint = bodyPaint,
+            titlePaint = titlePaint,
+            paddingLeftPx = ReadBookConfig.paddingLeft.dpToPx(),
+            paddingTopPx = ReadBookConfig.paddingTop.dpToPx(),
+            paddingRightPx = ReadBookConfig.paddingRight.dpToPx(),
+            paddingBottomPx = ReadBookConfig.paddingBottom.dpToPx(),
+            bodyTextHeightPx = bodyPaint.textHeight,
+            titleTextHeightPx = titlePaint.textHeight,
+            bodyBaselineOffsetPx = ReaderAndroidPaintFactory.baselineOffset(bodyPaint),
+            titleBaselineOffsetPx = ReaderAndroidPaintFactory.baselineOffset(titlePaint),
+            lineSpacingExtra = ReadBookConfig.lineSpacingExtra / 10f,
+            titleLineSpacingExtra = ReadBookConfig.titleLineSpacingExtra / 10f,
+            paragraphSpacing = ReadBookConfig.paragraphSpacing,
+            titleTopSpacingPx = ReadBookConfig.titleTopSpacing.dpToPx().toFloat(),
+            titleBottomSpacingPx = ReadBookConfig.titleBottomSpacing.dpToPx().toFloat(),
+            titleLineSpacingSub = ReadBookConfig.titleLineSpacingSub / 10f,
+            titleSegmentation = ReaderTitleSegmentation(
+                type = ReadBookConfig.titleSegType,
+                distance = ReadBookConfig.titleSegDistance,
+                delimiter = ReadBookConfig.titleSegFlag,
+                subtitleScale = ReadBookConfig.titleSegScaling,
+            ),
+        )
+    }
+
+    private fun resolveWeight(configured: Int): Int = when (configured) {
+        1 -> 900
+        2 -> 300
+        in 100..900 -> configured
+        else -> 400
+    }
+}

+ 163 - 0
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderStyleRangeMapper.kt

@@ -0,0 +1,163 @@
+package io.legado.app.feature.reader.legacy
+
+import io.legado.app.data.entities.BookContentProcess
+import io.legado.app.data.entities.HighlightRule
+import io.legado.app.domain.model.BookContentProcessEngine
+import io.legado.app.domain.model.TextProcessAnchor
+import io.legado.app.domain.model.TextProcessStyle
+import io.legado.app.feature.reader.core.model.ReaderUnderline
+import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
+import io.legado.app.feature.reader.core.model.withBitmapSize
+import io.legado.app.feature.reader.platform.ReaderTextBackgroundLoader
+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.style.ReaderCharacterStyle
+import io.legado.app.feature.reader.core.style.ReaderStyleRange
+import io.legado.app.feature.reader.core.style.ReaderStyleTarget
+import io.legado.app.utils.GSON
+import io.legado.app.utils.dpToPx
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.spToPx
+
+object LegacyReaderStyleRangeMapper {
+    fun map(
+        source: ReaderChapterSource,
+        rules: List<HighlightRule>,
+        processes: List<BookContentProcess>,
+    ): List<ReaderStyleRange> {
+        val result = mutableListOf<ReaderStyleRange>()
+        val bodyText = semanticBodyText(source)
+        val titleText = source.semanticTitle
+        rules.filter(HighlightRule::enabled).forEachIndexed { index, rule ->
+            val regex = runCatching { Regex(rule.pattern) }.getOrNull() ?: return@forEachIndexed
+            val targets = when (rule.targetScope) {
+                HighlightRule.TARGET_TITLE -> listOf(titleText to ReaderStyleTarget.TITLE)
+                HighlightRule.TARGET_BODY -> listOf(bodyText to ReaderStyleTarget.BODY)
+                else -> listOf(titleText to ReaderStyleTarget.TITLE, bodyText to ReaderStyleTarget.BODY)
+            }
+            targets.forEach { (text, target) ->
+                regex.findAll(text).forEach { match ->
+                    result += ReaderStyleRange(
+                        start = match.range.first,
+                        endExclusive = match.range.last + 1,
+                        target = target,
+                        style = rule.toReaderStyle(),
+                        priority = index,
+                    )
+                }
+            }
+        }
+        processes.asSequence()
+            .filter { it.enabled && it.status == BookContentProcess.STATUS_ACTIVE && it.isUserMarking() }
+            .forEachIndexed { index, process ->
+                val anchor = GSON.fromJsonObject<TextProcessAnchor>(process.anchorJson).getOrNull()
+                    ?: return@forEachIndexed
+                val markingStyle = GSON.fromJsonObject<TextProcessStyle>(process.styleJson).getOrNull()
+                    ?: return@forEachIndexed
+                val range = BookContentProcessEngine.resolveRange(bodyText, anchor) ?: return@forEachIndexed
+                result += ReaderStyleRange(
+                    start = range.first,
+                    endExclusive = range.last + 1,
+                    target = ReaderStyleTarget.BODY,
+                    style = markingStyle.toReaderStyle(process.id.removePrefix("mark:")),
+                    priority = 10_000 + index,
+                )
+            }
+        return result
+    }
+
+    private fun semanticBodyText(source: ReaderChapterSource): String {
+        val chars = CharArray(source.characterCount) { '\n' }
+        source.blocks.forEach { block ->
+            when (block) {
+                is ReaderChapterSourceBlock.Text -> if (!block.isTitle) {
+                    chars.writeText(block.chapterPosition, block.value)
+                }
+                is ReaderChapterSourceBlock.Image -> chars.setOrNull(block.chapterPosition, '\uFFFC')
+                is ReaderChapterSourceBlock.Paragraph -> block.items.forEach { item ->
+                    when (item) {
+                        is ReaderChapterInlineSource.Text -> chars.writeText(item.chapterPosition, item.value)
+                        is ReaderChapterInlineSource.Image -> chars.setOrNull(item.chapterPosition, '\uFFFC')
+                        is ReaderChapterInlineSource.BlankLine -> Unit
+                    }
+                }
+                is ReaderChapterSourceBlock.Html, is ReaderChapterSourceBlock.PageBreak -> Unit
+            }
+        }
+        return chars.concatToString()
+    }
+
+    private fun CharArray.setOrNull(index: Int, value: Char) {
+        if (index in indices) this[index] = value
+    }
+
+    private fun CharArray.writeText(start: Int, text: String) {
+        if (start !in indices || text.isEmpty()) return
+        val count = minOf(text.length, size - start)
+        for (offset in 0 until count) this[start + offset] = text[offset]
+    }
+
+    private fun HighlightRule.toReaderStyle() = ReaderCharacterStyle(
+        colorArgb = textColor,
+        backgroundArgb = bgColor,
+        underline = underlineMode.takeIf { it != 0 }?.let {
+            ReaderUnderline(
+                mode = it,
+                colorArgb = underlineColor ?: textColor ?: 0xFF63C37D.toInt(),
+                widthPx = underlineWidth.dpToPx(),
+                offsetPx = underlineOffset.dpToPx(),
+                svgPath = underlineSvgPath.orEmpty(),
+                dashOnPx = 8f.dpToPx(),
+                dashOffPx = 5f.dpToPx(),
+                waveAmplitudePx = 3f.dpToPx(),
+                waveLengthPx = 12f.dpToPx(),
+                doubleLineGapPx = 3f.dpToPx(),
+            )
+        },
+        fontPath = fontPath,
+        fontWeight = fontWeight,
+        italic = isItalic,
+        fontSizeOffsetPx = fontSizeOffset.toFloat().spToPx(),
+        backgroundImage = bgImage?.takeIf(String::isNotBlank)?.let {
+            ReaderTextBackgroundImage(
+                source = it,
+                fit = bgImageFit,
+                scale = bgImageScale,
+                ninePatchLeft = npLeft,
+                ninePatchRight = npRight,
+                ninePatchTop = npTop,
+                ninePatchBottom = npBottom,
+            ).let { image ->
+                val (width, height) = backgroundImageSize(it)
+                image.withBitmapSize(width, height)
+            }
+        },
+    )
+
+    private fun TextProcessStyle.toReaderStyle(markingId: String) = ReaderCharacterStyle(
+        colorArgb = textColor,
+        backgroundArgb = bgColor,
+        underline = underlineMode.takeIf { it != 0 }?.let {
+            ReaderUnderline(
+                mode = it,
+                colorArgb = underlineColor ?: textColor ?: 0xFF63C37D.toInt(),
+                widthPx = underlineWidth.dpToPx(),
+                offsetPx = underlineOffset.dpToPx(),
+                svgPath = underlineSvgPath.orEmpty(),
+                dashOnPx = 8f.dpToPx(),
+                dashOffPx = 5f.dpToPx(),
+                waveAmplitudePx = 3f.dpToPx(),
+                waveLengthPx = 12f.dpToPx(),
+                doubleLineGapPx = 3f.dpToPx(),
+            )
+        },
+        markingId = markingId,
+    )
+
+    private fun BookContentProcess.isUserMarking(): Boolean =
+        kind == BookContentProcess.KIND_USER_UNDERLINE || kind == BookContentProcess.KIND_USER_HIGHLIGHT
+
+    private fun backgroundImageSize(path: String): Pair<Int, Int> =
+        ReaderTextBackgroundLoader.dimensions(path)
+}

+ 175 - 0
app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderHtmlSourceResolver.kt

@@ -0,0 +1,175 @@
+package io.legado.app.feature.reader.platform
+
+import android.graphics.Color
+import android.graphics.Typeface
+import android.graphics.drawable.ColorDrawable
+import android.os.Build
+import android.text.Html
+import android.text.Spanned
+import android.text.Layout
+import android.text.style.AlignmentSpan
+import android.text.style.AbsoluteSizeSpan
+import android.text.style.BackgroundColorSpan
+import android.text.style.BulletSpan
+import android.text.style.ForegroundColorSpan
+import android.text.style.ImageSpan
+import android.text.style.LeadingMarginSpan
+import android.text.style.QuoteSpan
+import android.text.style.RelativeSizeSpan
+import android.text.style.StyleSpan
+import android.text.style.StrikethroughSpan
+import android.text.style.SubscriptSpan
+import android.text.style.SuperscriptSpan
+import android.text.style.TypefaceSpan
+import android.text.style.UnderlineSpan
+import android.text.style.URLSpan
+import androidx.core.text.HtmlCompat
+import io.legado.app.feature.reader.core.layout.ReaderHtmlSourceResolver
+import io.legado.app.feature.reader.core.layout.ReaderHtmlParagraph
+import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
+import io.legado.app.feature.reader.core.layout.ReaderParagraphDecoration
+import io.legado.app.feature.reader.core.layout.ReaderParagraphDecorationKind
+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.ReaderHtmlSemanticTextResolver
+
+object AndroidReaderHtmlSemanticTextResolver : ReaderHtmlSemanticTextResolver {
+    override fun resolve(html: String): String = parseReaderHtml(html).toString()
+}
+
+class AndroidReaderHtmlSourceResolver(
+    private val baseTextSizePx: Float,
+    private val density: Float,
+) : ReaderHtmlSourceResolver {
+    override fun resolve(
+        html: String,
+        chapterPosition: Int,
+    ): List<ReaderHtmlParagraph> {
+        val spanned = parseReaderHtml(html)
+        return splitParagraphs(spanned, chapterPosition)
+    }
+
+    private fun splitParagraphs(
+        text: Spanned,
+        chapterPosition: Int,
+    ): List<ReaderHtmlParagraph> {
+        val paragraphs = mutableListOf<ReaderHtmlParagraph>()
+        var paragraph = mutableListOf<ReaderChapterInlineSource>()
+        var paragraphStart = 0
+        var index = 0
+        var position = chapterPosition
+        fun finishParagraph(end: Int, preserveEmpty: Boolean = false) {
+            val probeEnd = (end + 1).coerceAtMost(text.length).coerceAtLeast(paragraphStart)
+            val margins = text.getSpans(paragraphStart, probeEnd, LeadingMarginSpan::class.java)
+            val firstMargin = margins.sumOf { it.getLeadingMargin(true) }.toFloat()
+            val restMargin = margins.sumOf { it.getLeadingMargin(false) }.toFloat()
+            val alignment = text.getSpans(paragraphStart, probeEnd, AlignmentSpan::class.java)
+                .lastOrNull()?.alignment?.toReaderAlignment()
+            var leadingOffset = 0f
+            val decorations = buildList {
+                margins.forEach { span ->
+                    when (span) {
+                        is QuoteSpan -> add(ReaderParagraphDecoration(
+                            ReaderParagraphDecorationKind.QUOTE,
+                            span.color,
+                            if (Build.VERSION.SDK_INT >= 28) span.stripeWidth.toFloat() else 2f,
+                            leadingOffset,
+                        ))
+                        is BulletSpan -> add(ReaderParagraphDecoration(
+                            ReaderParagraphDecorationKind.BULLET,
+                            span.color.takeUnless { it == 0 },
+                            if (Build.VERSION.SDK_INT >= 28) span.bulletRadius.toFloat() else 4f,
+                            leadingOffset,
+                        ))
+                    }
+                    leadingOffset += span.getLeadingMargin(true)
+                }
+            }
+            if (paragraph.isNotEmpty()) {
+                paragraphs += ReaderHtmlParagraph(paragraph, firstMargin, restMargin, alignment, decorations)
+            } else if (preserveEmpty) {
+                paragraphs += ReaderHtmlParagraph(
+                    listOf(ReaderChapterInlineSource.BlankLine(position)),
+                    firstMargin,
+                    restMargin,
+                    alignment,
+                    decorations,
+                )
+            }
+            paragraph = mutableListOf()
+            paragraphStart = (end + 1).coerceAtMost(text.length)
+        }
+        while (index < text.length) {
+            if (text[index] == '\n') {
+                // StaticLayout gives a newline-only line real height. Preserve only leading or
+                // consecutive empty paragraphs; its trailing start == end line is still omitted.
+                finishParagraph(index, preserveEmpty = paragraph.isEmpty())
+                index++
+                position++
+                continue
+            }
+            val image = text.getSpans(index, index + 1, ImageSpan::class.java).firstOrNull()
+            if (image != null) {
+                image.source?.let { paragraph += ReaderChapterInlineSource.Image(it, position) }
+                index++
+                position++
+                continue
+            }
+            val end = minOf(text.nextSpanTransition(index, text.length, Any::class.java), nextControl(text, index))
+                .coerceAtLeast(index + 1)
+            val value = text.subSequence(index, end).toString()
+            paragraph += ReaderChapterInlineSource.Text(value, position, styleAt(text, index))
+            position += value.length
+            index = end
+        }
+        finishParagraph(text.length)
+        return paragraphs
+    }
+
+    private fun nextControl(text: CharSequence, start: Int): Int {
+        for (index in start until text.length) {
+            if (text[index] == '\n' || text[index] == '\uFFFC') return index
+        }
+        return text.length
+    }
+
+    private fun styleAt(text: Spanned, index: Int): ReaderInlineSourceStyle {
+        val spans = text.getSpans(index, index + 1, Any::class.java)
+        val styleSpans = spans.filterIsInstance<StyleSpan>()
+        val bold = styleSpans.any { it.style == Typeface.BOLD || it.style == Typeface.BOLD_ITALIC }
+        val italic = styleSpans.any { it.style == Typeface.ITALIC || it.style == Typeface.BOLD_ITALIC }
+        val relative = spans.filterIsInstance<RelativeSizeSpan>().fold(1f) { scale, span -> scale * span.sizeChange }
+        val absolute = spans.filterIsInstance<AbsoluteSizeSpan>().lastOrNull()?.let {
+            val px = if (it.dip) it.size * density else it.size.toFloat()
+            px / baseTextSizePx.coerceAtLeast(1f)
+        }
+        return ReaderInlineSourceStyle(
+            colorArgb = spans.filterIsInstance<ForegroundColorSpan>().lastOrNull()?.foregroundColor,
+            backgroundArgb = spans.filterIsInstance<BackgroundColorSpan>().lastOrNull()?.backgroundColor,
+            fontWeight = if (bold) 700 else null,
+            italic = if (italic) true else null,
+            underline = spans.any { it is UnderlineSpan },
+            strikeThrough = spans.any { it is StrikethroughSpan },
+            link = spans.filterIsInstance<URLSpan>().lastOrNull()?.url,
+            fontSizeScale = absolute ?: relative,
+            fontFamily = spans.filterIsInstance<TypefaceSpan>().lastOrNull()?.family,
+            superscript = spans.any { it is SuperscriptSpan },
+            subscript = spans.any { it is SubscriptSpan },
+        )
+    }
+}
+
+private fun Layout.Alignment.toReaderAlignment(): ReaderTextAlignment = when (this) {
+    Layout.Alignment.ALIGN_CENTER -> ReaderTextAlignment.CENTER
+    Layout.Alignment.ALIGN_OPPOSITE -> ReaderTextAlignment.END
+    else -> ReaderTextAlignment.START
+}
+
+private fun parseReaderHtml(html: String): Spanned = HtmlCompat.fromHtml(
+    html,
+    HtmlCompat.FROM_HTML_MODE_COMPACT,
+    Html.ImageGetter {
+        ColorDrawable(Color.TRANSPARENT).apply { setBounds(0, 0, 1, 1) }
+    },
+    null,
+)

+ 84 - 0
app/src/main/java/io/legado/app/feature/reader/platform/AndroidReaderTextShaper.kt

@@ -0,0 +1,84 @@
+package io.legado.app.feature.reader.platform
+
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.os.Build
+import android.text.TextPaint
+import androidx.core.net.toUri
+import io.legado.app.feature.reader.core.layout.GlyphClusters
+import io.legado.app.feature.reader.core.layout.ReaderTextShaper
+import io.legado.app.feature.reader.core.layout.ReaderFontBounds
+import io.legado.app.feature.reader.core.layout.ReaderFontLineMetrics
+import io.legado.app.feature.reader.core.layout.clusterGlyphs
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import java.io.File
+import splitties.init.appCtx
+
+object ReaderAndroidPaintFactory {
+    fun create(style: ReaderTextStyle): Paint =
+        Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG).apply {
+            color = style.colorArgb
+            textSize = style.fontSizePx
+            typeface = loadTypeface(style.fontPath, style.fontWeight, false, style.fontFamily)
+            // Match the reader's synthetic italic; don't substitute another font's italic face.
+            textSkewX = if (style.italic) -0.25f else 0f
+            isLinearText = style.linearText
+            isStrikeThruText = style.strikeThrough
+            isUnderlineText = style.nativeUnderline
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+                setFontVariationSettings("'wght' ${style.fontWeight}")
+            }
+            style.shadow?.let { setShadowLayer(it.radiusPx, it.dxPx, it.dyPx, it.colorArgb) }
+        }
+
+    fun createTextPaint(style: ReaderTextStyle): TextPaint = TextPaint(create(style))
+
+    /** The reader's line box uses descent - ascent + leading; baseline = line bottom - descent. */
+    fun baselineOffset(paint: Paint): Float = paint.fontMetrics.let { it.leading - it.ascent }
+
+    fun loadTypeface(path: String, weight: Int, italic: Boolean, family: String = "sans-serif"): Typeface {
+        val base = runCatching {
+            when {
+                path.startsWith("content://", ignoreCase = true) ->
+                    appCtx.contentResolver.openFileDescriptor(path.toUri(), "r")?.use {
+                        Typeface.Builder(it.fileDescriptor).build()
+                    }
+                path.isNotBlank() && File(path).isFile -> Typeface.Builder(File(path)).build()
+                else -> null
+            }
+        }.getOrNull() ?: Typeface.create(family, Typeface.NORMAL)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+            return Typeface.create(base, weight.coerceIn(1, 1000), italic)
+        }
+        val typefaceStyle = when {
+            weight >= 600 && italic -> Typeface.BOLD_ITALIC
+            weight >= 600 -> Typeface.BOLD
+            italic -> Typeface.ITALIC
+            else -> Typeface.NORMAL
+        }
+        return Typeface.create(base, typefaceStyle)
+    }
+}
+
+/** Android shaping boundary. The paint must match the paint used by ReaderCanvasSurface. */
+class AndroidReaderTextShaper(paint: TextPaint) : ReaderTextShaper {
+    // Pagination inserts the gap between clusters. Do not include it again in glyph widths.
+    private val paint = TextPaint(paint).apply { letterSpacing = 0f }
+    override val fontBounds = this.paint.fontMetrics.let { ReaderFontBounds(it.top, it.bottom, it.descent) }
+    override val fontLineMetrics = this.paint.fontMetrics.let {
+        val height = it.descent - it.ascent + it.leading
+        ReaderFontLineMetrics(
+            heightPx = height,
+            baselineOffsetPx = height - it.descent,
+            ascentPx = -it.ascent,
+            descentPx = it.descent,
+        )
+    }
+
+    override fun shape(text: String): GlyphClusters {
+        if (text.isEmpty()) return GlyphClusters(emptyList(), emptyList())
+        val widths = FloatArray(text.length)
+        paint.getTextWidths(text, widths)
+        return clusterGlyphs(text, widths)
+    }
+}

+ 40 - 0
app/src/main/java/io/legado/app/feature/reader/platform/ReaderAndroidPaginationStyle.kt

@@ -0,0 +1,40 @@
+package io.legado.app.feature.reader.platform
+
+import android.text.TextPaint
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import io.legado.app.feature.reader.core.model.ReaderEmphasisUnderline
+import io.legado.app.feature.reader.core.layout.ReaderColumnMode
+import io.legado.app.feature.reader.core.layout.ReaderPageUnderline
+import io.legado.app.feature.reader.core.source.ReaderTitleSegmentation
+
+/** Immutable-per-pagination Android shaping input, independent of the legacy page model. */
+data class ReaderAndroidPaginationStyle(
+    val bodyPaint: TextPaint,
+    val titlePaint: TextPaint,
+    val bodyStyle: ReaderTextStyle,
+    val titleStyle: ReaderTextStyle,
+    val paddingLeftPx: Int,
+    val paddingTopPx: Int,
+    val paddingRightPx: Int,
+    val paddingBottomPx: Int,
+    val bodyTextHeightPx: Float,
+    val titleTextHeightPx: Float,
+    val bodyBaselineOffsetPx: Float,
+    val titleBaselineOffsetPx: Float,
+    val lineSpacingExtra: Float,
+    val titleLineSpacingExtra: Float,
+    val paragraphSpacing: Int,
+    val titleTopSpacingPx: Float = 0f,
+    val titleBottomSpacingPx: Float = 0f,
+    val titleLineSpacingSub: Float = 0f,
+    val titleSegmentation: ReaderTitleSegmentation = ReaderTitleSegmentation(),
+    val columnMode: ReaderColumnMode = ReaderColumnMode.SINGLE,
+    val isTablet: Boolean = false,
+    val isScroll: Boolean = false,
+    val textBottomJustify: Boolean = false,
+    val pageUnderline: ReaderPageUnderline? = null,
+    val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
+) {
+    fun columnCount(widthPx: Int, heightPx: Int): Int =
+        columnMode.columnCount(widthPx, heightPx, isTablet, isScroll)
+}

+ 97 - 0
app/src/main/java/io/legado/app/feature/reader/platform/ReaderBookmarkBadgeRenderer.kt

@@ -0,0 +1,97 @@
+package io.legado.app.feature.reader.platform
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.RectF
+import com.caverock.androidsvg.SVG
+import io.legado.app.feature.reader.core.model.ReaderBookmarkBadge
+import java.io.File
+import kotlin.math.min
+
+/** Android decoding stays outside the drawing pass. A single custom badge is shared by the page window. */
+object ReaderBookmarkBadgeRenderer {
+    private data class ImageKey(val source: String, val version: String, val width: Int, val height: Int)
+    private var cachedKey: ImageKey? = null
+    private var cachedBitmap: Bitmap? = null
+    private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
+    private val ribbon = Path().apply {
+        // Same 12 x 24 viewport as ic_bookmark_badge.xml.
+        moveTo(2f, 2f)
+        lineTo(10f, 2f)
+        lineTo(10f, 22f)
+        lineTo(6f, 17f)
+        lineTo(2f, 22f)
+        close()
+    }
+
+    /** Call on an IO dispatcher, never from Canvas.draw. Invalid custom files use the default ribbon. */
+    @Synchronized
+    fun load(badge: ReaderBookmarkBadge): Bitmap? {
+        val key = ImageKey(badge.imageSource, badge.imageVersion, badge.widthPx, badge.heightPx)
+        if (key == cachedKey) return cachedBitmap
+        val bitmap = badge.imageSource.takeIf(String::isNotBlank)?.let { path ->
+            runCatching {
+                val file = File(path)
+                if (!file.isFile) return@runCatching null
+                val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+                BitmapFactory.decodeFile(path, bounds)
+                if (bounds.outWidth > 0 && bounds.outHeight > 0) {
+                    var sample = 1
+                    while (bounds.outWidth / (sample * 2) >= badge.widthPx * 2 &&
+                        bounds.outHeight / (sample * 2) >= badge.heightPx * 2
+                    ) sample *= 2
+                    BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
+                } else {
+                    val svg = file.inputStream().use(SVG::getFromInputStream)
+                    val width = svg.documentWidth.takeIf { it > 0 } ?: svg.documentViewBox?.width() ?: 0f
+                    val height = svg.documentHeight.takeIf { it > 0 } ?: svg.documentViewBox?.height() ?: 0f
+                    if (width <= 0 || height <= 0) return@runCatching null
+                    val scale = min(badge.widthPx / width, badge.heightPx / height)
+                    Bitmap.createBitmap((width * scale).toInt().coerceAtLeast(1),
+                        (height * scale).toInt().coerceAtLeast(1), Bitmap.Config.ARGB_8888).also {
+                        if (svg.documentViewBox == null) svg.setDocumentViewBox(0f, 0f, width, height)
+                        svg.setDocumentWidth("100%")
+                        svg.setDocumentHeight("100%")
+                        svg.renderToCanvas(Canvas(it))
+                    }
+                }
+            }.getOrNull()
+        }
+        // Do not recycle the previous bitmap: a leaving page may still be drawing it.
+        cachedKey = key
+        cachedBitmap = bitmap
+        return bitmap
+    }
+
+    /** Returns null while this badge identity has not been decoded; a pair with null bitmap means decoding failed. */
+    @Synchronized
+    fun cachedResult(badge: ReaderBookmarkBadge): Pair<ReaderBookmarkBadge, Bitmap?>? {
+        val key = ImageKey(badge.imageSource, badge.imageVersion, badge.widthPx, badge.heightPx)
+        return if (key == cachedKey) badge to cachedBitmap?.takeUnless(Bitmap::isRecycled) else null
+    }
+
+    fun draw(canvas: Canvas, badge: ReaderBookmarkBadge, bitmap: Bitmap?) {
+        val checkpoint = canvas.save()
+        try {
+            canvas.translate(badge.leftPx, badge.topPx)
+            canvas.clipRect(0f, 0f, badge.widthPx.toFloat(), badge.heightPx.toFloat())
+            if (bitmap != null && !bitmap.isRecycled) {
+                val scale = min(badge.widthPx.toFloat() / bitmap.width, badge.heightPx.toFloat() / bitmap.height)
+                val width = bitmap.width * scale
+                val height = bitmap.height * scale
+                val left = (badge.widthPx - width) / 2
+                val top = (badge.heightPx - height) / 2
+                canvas.drawBitmap(bitmap, null, RectF(left, top, left + width, top + height), paint)
+            } else {
+                canvas.scale(badge.widthPx / 12f, badge.heightPx / 24f)
+                paint.color = 0xffffc107.toInt()
+                canvas.drawPath(ribbon, paint)
+            }
+        } finally {
+            canvas.restoreToCount(checkpoint)
+        }
+    }
+}

+ 137 - 0
app/src/main/java/io/legado/app/feature/reader/platform/ReaderPageDecorationDrawCache.kt

@@ -0,0 +1,137 @@
+package io.legado.app.feature.reader.platform
+
+import android.graphics.Canvas
+import android.graphics.DashPathEffect
+import android.graphics.Paint
+import android.graphics.Path
+import android.util.LruCache
+import androidx.core.graphics.PathParser
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderRect
+import io.legado.app.feature.reader.core.model.ReaderUnderline
+import io.legado.app.feature.reader.core.model.underlineRuns
+
+/** Immutable Android draw data prepared once for a page snapshot revision. */
+internal data class ReaderPageDecorationDrawCache(
+    val contentRules: List<ReaderRuleDrawCommand>,
+    val styledUnderlines: List<ReaderUnderlineDrawCommand>,
+    val overlayRules: List<ReaderRuleDrawCommand>,
+) {
+    companion object {
+        fun create(page: ReaderPage) = ReaderPageDecorationDrawCache(
+            contentRules = page.elements.filterIsInstance<ReaderElement.Rule>()
+                .filterNot(ReaderElement.Rule::overlayStyledUnderline)
+                .map(::ReaderRuleDrawCommand),
+            styledUnderlines = page.underlineRuns().map { run ->
+                ReaderUnderlineDrawCommand(run.bounds, run.underline)
+            },
+            overlayRules = page.elements.filterIsInstance<ReaderElement.Rule>()
+                .filter(ReaderElement.Rule::overlayStyledUnderline)
+                .map(::ReaderRuleDrawCommand),
+        )
+    }
+}
+
+internal class ReaderRuleDrawCommand(private val rule: ReaderElement.Rule) {
+    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = rule.colorArgb
+        strokeWidth = rule.widthPx.coerceAtLeast(1f)
+        if (rule.dashed) {
+            pathEffect = DashPathEffect(
+                floatArrayOf(rule.dashOnPx.coerceAtLeast(0.1f), rule.dashOffPx.coerceAtLeast(0.1f)),
+                0f,
+            )
+        }
+    }
+
+    fun draw(canvas: Canvas) {
+        canvas.drawLine(rule.bounds.left, rule.bounds.top, rule.bounds.right, rule.bounds.bottom, paint)
+    }
+}
+
+internal class ReaderUnderlineDrawCommand(
+    private val bounds: ReaderRect,
+    private val underline: ReaderUnderline,
+) {
+    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        color = underline.colorArgb
+        strokeWidth = underline.widthPx.coerceAtLeast(1f)
+        style = Paint.Style.STROKE
+        strokeCap = Paint.Cap.BUTT
+    }
+    private val wavePath = if (underline.mode == 3) createWavePath(bounds, underline) else null
+    private val svgPath = if (underline.mode == 5) ReaderSvgPathCache.parse(underline.svgPath) else null
+
+    fun draw(canvas: Canvas) {
+        val start = bounds.left
+        val end = bounds.right
+        val y = bounds.bottom + underline.offsetPx
+        when (underline.mode) {
+            1 -> canvas.drawLine(start, y, end, y, paint)
+            2 -> drawDashed(canvas, start, end, y)
+            3 -> wavePath?.let { canvas.drawPath(it, paint) }
+            4 -> {
+                canvas.drawLine(start, y, end, y, paint)
+                val secondY = y + underline.doubleLineGapPx + underline.widthPx
+                canvas.drawLine(start, secondY, end, secondY, paint)
+            }
+            5 -> svgPath?.takeIf { end > start }?.let { path ->
+                canvas.save()
+                canvas.translate(start, y - SVG_BASELINE_Y)
+                canvas.scale((end - start) / SVG_BASE_WIDTH, 1f)
+                canvas.drawPath(path, paint)
+                canvas.restore()
+            }
+        }
+    }
+
+    private fun drawDashed(canvas: Canvas, start: Float, end: Float, y: Float) {
+        val dashOn = underline.dashOnPx.coerceAtLeast(MIN_SEGMENT_PX)
+        val dashOff = underline.dashOffPx.coerceAtLeast(MIN_SEGMENT_PX)
+        var x = start
+        while (x < end) {
+            canvas.drawLine(x, y, (x + dashOn).coerceAtMost(end), y, paint)
+            x += dashOn + dashOff
+        }
+    }
+
+    private companion object {
+        const val MIN_SEGMENT_PX = 0.1f
+        const val SVG_BASE_WIDTH = 100f
+        const val SVG_BASELINE_Y = 50f
+
+        fun createWavePath(bounds: ReaderRect, underline: ReaderUnderline): Path {
+            val y = bounds.bottom + underline.offsetPx
+            val waveLength = underline.waveLengthPx.coerceAtLeast(MIN_SEGMENT_PX)
+            return Path().apply {
+                moveTo(bounds.left, y)
+                var x = bounds.left
+                while (x < bounds.right) {
+                    val next = (x + waveLength).coerceAtMost(bounds.right)
+                    quadTo((x + next) / 2f, y - underline.waveAmplitudePx, next, y)
+                    x = next
+                    if (x < bounds.right) {
+                        val nextDown = (x + waveLength).coerceAtMost(bounds.right)
+                        quadTo((x + nextDown) / 2f, y + underline.waveAmplitudePx, nextDown, y)
+                        x = nextDown
+                    }
+                }
+            }
+        }
+    }
+}
+
+internal object ReaderSvgPathCache {
+    private val cache = LruCache<String, Path>(32)
+
+    fun parse(pathData: String): Path? {
+        if (pathData.isBlank()) return null
+        cache.get(pathData)?.let { return it }
+        val path = runCatching { PathParser.createPathFromPathData(pathData) }.getOrNull() ?: return null
+        cache.put(pathData, path)
+        return path
+    }
+
+    fun clear() = cache.evictAll()
+}

+ 96 - 0
app/src/main/java/io/legado/app/feature/reader/platform/ReaderTextBackgroundLoader.kt

@@ -0,0 +1,96 @@
+package io.legado.app.feature.reader.platform
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.util.LruCache
+import splitties.init.appCtx
+import java.io.File
+import java.io.InputStream
+
+/** Android resource boundary shared by background measurement and Canvas drawing. */
+object ReaderTextBackgroundLoader {
+    private val bitmaps = object : LruCache<String, Bitmap>(16 * 1024 * 1024) {
+        override fun sizeOf(key: String, value: Bitmap): Int = value.allocationByteCount
+    }
+
+    fun dimensions(source: String): Pair<Int, Int> = runCatching {
+        open(source)?.use { input ->
+            BitmapFactory.Options().run {
+                inJustDecodeBounds = true
+                BitmapFactory.decodeStream(input, null, this)
+                outWidth.coerceAtLeast(0) to outHeight.coerceAtLeast(0)
+            }
+        }
+    }.getOrNull() ?: (0 to 0)
+
+    fun load(source: String): Bitmap? {
+        if (source.isBlank()) return null
+        val key = cacheKey(source)
+        cached(source)?.let { return it }
+        val dimensions = dimensions(source)
+        if (dimensions.first <= 0 || dimensions.second <= 0) return null
+        val sampleSize = if (isRawNinePatch(source)) 1 else calculateInSampleSize(
+            width = dimensions.first,
+            height = dimensions.second,
+            requestedWidth = appCtx.resources.displayMetrics.widthPixels,
+            requestedHeight = appCtx.resources.displayMetrics.heightPixels,
+        )
+        return runCatching {
+            open(source)?.use { input ->
+                BitmapFactory.decodeStream(
+                    input,
+                    null,
+                    BitmapFactory.Options().apply { inSampleSize = sampleSize },
+                )
+            }
+        }.getOrNull()?.takeUnless(Bitmap::isRecycled)?.also { bitmaps.put(key, it) }
+    }
+
+    fun cached(source: String): Bitmap? = source.takeIf(String::isNotBlank)
+        ?.let(::cacheKey)
+        ?.let(bitmaps::get)
+        ?.takeUnless(Bitmap::isRecycled)
+
+    internal fun assetCandidates(source: String): List<String> = when {
+        source.startsWith("assets://") -> listOf(source.removePrefix("assets://"))
+        source.startsWith("content://") || File(source).exists() -> emptyList()
+        source.startsWith("bg/") -> listOf(source)
+        else -> listOf("bg/$source")
+    }
+
+    private fun open(source: String): InputStream? {
+        if (source.isBlank()) return null
+        return when {
+            source.startsWith("assets://") -> appCtx.assets.open(source.removePrefix("assets://"))
+            source.startsWith("content://") -> appCtx.contentResolver.openInputStream(Uri.parse(source))
+            File(source).exists() -> File(source).inputStream()
+            else -> assetCandidates(source).firstNotNullOfOrNull { asset ->
+                runCatching { appCtx.assets.open(asset) }.getOrNull()
+            }
+        }
+    }
+
+    private fun cacheKey(source: String): String {
+        val file = File(source)
+        return if (file.isFile) "$source:${file.length()}:${file.lastModified()}" else source
+    }
+
+    private fun isRawNinePatch(source: String): Boolean =
+        source.substringBefore('?').substringBefore('#').endsWith(".9.png", ignoreCase = true)
+
+    private fun calculateInSampleSize(
+        width: Int,
+        height: Int,
+        requestedWidth: Int,
+        requestedHeight: Int,
+    ): Int {
+        var sample = 1
+        if (height > requestedHeight || width > requestedWidth) {
+            val halfHeight = height / 2
+            val halfWidth = width / 2
+            while (halfHeight / sample >= requestedHeight && halfWidth / sample >= requestedWidth) sample *= 2
+        }
+        return sample
+    }
+}

+ 0 - 19
app/src/main/java/io/legado/app/help/readaloud/segment/TextChapterSpeechAdapter.kt

@@ -1,19 +0,0 @@
-package io.legado.app.help.readaloud.segment
-
-import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
-import io.legado.app.ui.book.read.page.entities.TextChapter
-
-fun TextChapter.toCanonicalSpeechParagraphs(): List<CanonicalSpeechParagraph> {
-    if (!isCompleted) return emptyList()
-    return paragraphs
-        .asSequence()
-        .filter { it.textLines.isNotEmpty() }
-        .mapIndexed { fallbackIndex, paragraph ->
-            CanonicalSpeechParagraph(
-                index = (paragraph.realNum - 1).takeIf { it >= 0 } ?: fallbackIndex,
-                text = paragraph.text.replace(Regex("[袮祢꧁]"), " "),
-                chapterPosition = paragraph.chapterPosition,
-            )
-        }
-        .toList()
-}

+ 1 - 1
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -356,7 +356,7 @@ object Restore : KoinComponent {
             get<ReadStyleGateway>().refresh()
             // refresh 只重建 Compose 侧 state;阅读器开着时渲染层的两份快照(RenderStyle/
             // TipStyle)与已排版内容不会跟着刷新,得走配置总线让 controller 重建并重排。
-            // 阅读器没开时无人消费,重开由 ReadView.init 的重建入口兜底。
+            // 阅读器没开时无人消费,重开后由 Compose 阅读路由的分页重建入口兜底。
             ReadConfigUpdateBus.post(
                 setOf(
                     ConfigUpdateAction.UpdateBackground,

+ 7 - 2
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -119,7 +119,9 @@ object ReadAloud {
         context: Context,
         play: Boolean = true,
         pageIndex: Int = ReadBook.durPageIndex,
-        startPos: Int = 0
+        startPos: Int = 0,
+        /** Absolute position in the processed chapter; preferred by the Canvas reader. */
+        chapterPosition: Int? = null,
     ) {
         if (!BaseReadAloudService.isRun) {
             aloudClass = getReadAloudClass()
@@ -129,6 +131,7 @@ object ReadAloud {
         intent.putExtra("play", play)
         intent.putExtra("pageIndex", pageIndex)
         intent.putExtra("startPos", startPos)
+        chapterPosition?.let { intent.putExtra("chapterPosition", it) }
         LogUtils.d("ReadAloud", intent.toString())
         try {
             context.startForegroundServiceCompat(intent)
@@ -142,12 +145,14 @@ object ReadAloud {
     fun playByEventBus(
         play: Boolean = true,
         pageIndex: Int = ReadBook.durPageIndex,
-        startPos: Int = 0
+        startPos: Int = 0,
+        chapterPosition: Int? = null,
     ) {
         val bundle = Bundle().apply {
             putBoolean("play", play)
             putInt("pageIndex", pageIndex)
             putInt("startPos", startPos)
+            chapterPosition?.let { putInt("chapterPosition", it) }
         }
         postEvent(EventBus.READ_ALOUD_PLAY, bundle)
     }

+ 231 - 278
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -25,6 +25,7 @@ import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.update
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.globalExecutor
 import io.legado.app.model.ReadBook.callBack
@@ -41,10 +42,15 @@ import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.CacheBookService
 import io.legado.app.ui.book.read.ConfigUpdateAction
 import io.legado.app.ui.book.read.ReadConfigUpdateBus
-import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.entities.TextPage
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
+import io.legado.app.feature.reader.core.navigation.ReaderChapterPaginationSnapshot
+import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
+import io.legado.app.feature.reader.platform.AndroidReaderHtmlSemanticTextResolver
+import io.legado.app.feature.reader.legacy.LegacyReaderChapterPaginationResult
+import io.legado.app.feature.reader.legacy.LegacyReaderChapterPaginator
+import io.legado.app.feature.reader.legacy.LegacyReaderPageDecorationFactory
+import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
+import io.legado.app.model.reader.ReaderChapterInput
+import io.legado.app.model.reader.ReaderChapterInputWindow
 import io.legado.app.ui.book.read.pageestimate.ChapterContentHasher
 import io.legado.app.ui.book.read.pageestimate.ChapterLengthInfo
 import io.legado.app.ui.book.read.pageestimate.LocalPageEstimateCalibrationStore
@@ -65,7 +71,6 @@ import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.TimeoutCancellationException
 import kotlinx.coroutines.cancelChildren
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
@@ -76,11 +81,8 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Semaphore
-import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
-import kotlinx.coroutines.withTimeout
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 import splitties.init.appCtx
@@ -109,6 +111,16 @@ data class LegacyReaderSnapshot(
     val isLocalBook: Boolean = true,
 )
 
+data class ReaderPaginationEnvironment(
+    val widthPx: Int,
+    val heightPx: Int,
+    val style: ReaderAndroidPaginationStyle,
+    val contentPaddingLeftPx: Int = 0,
+    val contentPaddingTopPx: Int = 0,
+    val contentPaddingRightPx: Int = 0,
+    val contentPaddingBottomPx: Int = 0,
+)
+
 @Suppress("MemberVisibilityCanBePrivate")
 object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var book: Book? = null
@@ -137,11 +149,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var isLocalBook = true
     var chapterChanged = false
     @Volatile
-    var prevTextChapter: TextChapter? = null
+    var readerChapterInputWindow = ReaderChapterInputWindow()
+        private set
     @Volatile
-    var curTextChapter: TextChapter? = null
+    private var readerPaginationSnapshots = emptyMap<Int, ReaderChapterPaginationSnapshot>()
     @Volatile
-    var nextTextChapter: TextChapter? = null
+    private var readerPaginationEnvironment: ReaderPaginationEnvironment? = null
     var bookSource: BookSource? = null
     var msg: String? = null
     private val readRecordRepository: ReadRecordRepository by inject()
@@ -151,13 +164,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private val readRecord = ReadRecord()
     private val chapterLayoutScheduler = LatestChapterTaskScheduler<ChapterLayoutTaskKey>(this) {
             _, error ->
-        AppLog.put("ChapterProvider ERROR", error)
-        appCtx.toastOnUi("ChapterProvider ERROR:\n${error.stackTraceStr}")
+        AppLog.put("Reader chapter load ERROR", error)
+        appCtx.toastOnUi("Reader chapter load ERROR:\n${error.stackTraceStr}")
     }
     private val translationObserverJobs = ConcurrentHashMap<Int, Job>()
-    private val prevChapterLoadingLock = Mutex()
-    private val curChapterLoadingLock = Mutex()
-    private val nextChapterLoadingLock = Mutex()
     var readStartTime: Long = System.currentTimeMillis()
     var isUiActive = false
     val isAutoSaveSessionRunning: Boolean
@@ -236,7 +246,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         if (!BaseReadAloudService.isRun || BaseReadAloudService.speechDrivingNavigation) return
         val speakingChapterIndex = BaseReadAloudService.currentChapterIndex
         if (speakingChapterIndex < 0 || speakingChapterIndex != durChapterIndex) return
-        val speakingPage = curTextChapter?.getPageIndexByCharIndex(
+        val speakingPage = readerPagination()?.pageIndex(
             BaseReadAloudService.currentProgress.coerceAtLeast(0)
         ) ?: return
         if (speakingPage == durPageIndex) {
@@ -320,16 +330,6 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             durChapterPos = book.durChapterPos
             clearTextChapter()
         }
-        // 还在排版中的当前章不能丢: 丢了会重新加载一遍, 反而多闪一次占位页
-        if (curTextChapter?.isCompleted == false && curTextChapter?.isLayoutRunning != true) {
-            curTextChapter = null
-        }
-        if (nextTextChapter?.isCompleted == false) {
-            nextTextChapter = null
-        }
-        if (prevTextChapter?.isCompleted == false) {
-            prevTextChapter = null
-        }
         callBack?.upMenuView()
         upWebBook(book)
         synchronized(this) {
@@ -353,36 +353,45 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             wholeBookPageCoordinator.clear()
             return
         }
-        val contentWidth = ChapterProvider.visibleWidth
-        val contentHeight = ChapterProvider.visibleHeight
+        val environment = readerPaginationEnvironment ?: return
+        val style = environment.style
+        val columns = style.columnCount(environment.widthPx, environment.heightPx)
+        val contentWidth = environment.widthPx / columns - style.paddingLeftPx - style.paddingRightPx -
+            environment.contentPaddingLeftPx - environment.contentPaddingRightPx
+        val contentHeight = (environment.heightPx - style.paddingTopPx - style.paddingBottomPx -
+            environment.contentPaddingTopPx - environment.contentPaddingBottomPx -
+            LegacyReaderPageDecorationFactory.headerExtentPx() -
+            LegacyReaderPageDecorationFactory.footerExtentPx()).toInt()
         if (contentWidth <= 0 || contentHeight <= 0) return
         val processor = contentProcessor ?: ContentProcessor.get(book)
 
         val config = PageEstimateConfig(
+            // Canvas uses different line/page boundaries; never reuse View-engine exact counts.
+            textEngineVersion = 14,
+            titleLayoutKey = "${style.titleSegmentation}|${style.titleLineSpacingSub}",
             readerType = pageAnim(),
-            textSizePx = ChapterProvider.contentPaint.textSize,
-            textHeightPx = ChapterProvider.contentPaintTextHeight,
-            lineSpacingPx = ChapterProvider.contentPaintTextHeight *
-                (ChapterProvider.lineSpacingExtra - 1f),
-            paragraphSpacingPx = ChapterProvider.contentPaintTextHeight *
-                ChapterProvider.paragraphSpacing / 10f,
-            titleTextSizePx = ChapterProvider.titlePaint.textSize,
-            titleTextHeightPx = ChapterProvider.titlePaintTextHeight,
-            titleLineSpacingPx = ChapterProvider.titlePaintTextHeight *
-                (ChapterProvider.titleLineSpacingExtra - 1f),
-            titleTopSpacingPx = ChapterProvider.titleTopSpacing.toFloat(),
-            titleBottomSpacingPx = ChapterProvider.titleBottomSpacing.toFloat(),
+            textSizePx = style.bodyPaint.textSize,
+            textHeightPx = style.bodyTextHeightPx,
+            lineSpacingPx = style.bodyTextHeightPx * style.lineSpacingExtra,
+            paragraphSpacingPx = style.bodyTextHeightPx * style.paragraphSpacing / 10f,
+            titleTextSizePx = style.titlePaint.textSize,
+            titleTextHeightPx = style.titleTextHeightPx,
+            titleLineSpacingPx = style.titleTextHeightPx * style.titleLineSpacingExtra,
+            titleTopSpacingPx = style.titleTopSpacingPx,
+            titleBottomSpacingPx = style.titleBottomSpacingPx,
             endPaddingPx = 20.dpToPx().toFloat(),
             contentWidthPx = contentWidth,
-            contentHeightPx = contentHeight,
-            fontKey = ReadBookConfig.textFont,
-            titleFontKey = ReadBookConfig.titleFont,
+            // The estimator counts screen turns, not columns: both columns contribute capacity.
+            contentHeightPx = contentHeight * columns,
+            fontKey = "${style.bodyStyle.fontPath}|${style.bodyStyle.fontWeight}|${style.bodyStyle.fontFamily}|${style.bodyStyle.italic}",
+            titleFontKey = "${style.titleStyle.fontPath}|${style.titleStyle.fontWeight}|${style.titleStyle.fontFamily}|${style.titleStyle.italic}",
             letterSpacing = ReadBookConfig.letterSpacing,
             paragraphIndent = ReadBookConfig.paragraphIndent,
             titleMode = ReadBookConfig.titleMode,
-            doublePage = ChapterProvider.doublePage,
+            doublePage = columns == 2,
             useZhLayout = ReadBookConfig.useZhLayout,
             textFullJustify = ReadBookConfig.textFullJustify,
+            imageLayoutKey = book.getImageStyle().orEmpty(),
             contentKey = buildString {
                 append(book.config.useReplaceRule)
                 append('|').append(book.config.delTag)
@@ -458,6 +467,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         val chapters = withContext(IO) { appDb.bookChapterDao.getChapterList(book.bookUrl) }
         val processor = ContentProcessor.get(book)
         val useReplaceRule = book.getUseReplaceRule(AppConfig.replaceEnableDefault)
+        val highlightRules = HighlightRuleRepository().loadEnabled(ReadBookConfig.durConfig.name)
         for (chapter in chapters) {
             ensureActive()
             if (wholeBookPageCoordinator.isChapterExact(chapter.index, layoutGeneration)) continue
@@ -481,24 +491,40 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 },
                 layoutGeneration = layoutGeneration,
             )
-            val textChapter = ChapterProvider.getTextChapterAsync(
-                this, book, bookSource, chapter, displayTitle, processed, chapters.size,
+            val source = ReaderChapterSourceParser.parse(
+                chapterIndex = chapter.index,
+                title = displayTitle,
+                paragraphs = processed.textList,
+                includeTitle = ReadBookConfig.titleMode != 2 || chapter.isVolume || processed.textList.isEmpty(),
+                adaptSpecialStyle = ReadConfig.adaptSpecialStyle,
+                htmlSemanticTextResolver = AndroidReaderHtmlSemanticTextResolver,
             )
-            try {
-                var pageCount = 0
-                withTimeout(30_000L) {
-                    for (page in textChapter.layoutChannel) {
-                        ensureActive()
-                        pageCount++
-                    }
-                }
+            val environment = readerPaginationEnvironment ?: return
+            when (val result = LegacyReaderChapterPaginator.paginate(
+                book = book,
+                bookSource = bookSource,
+                chapter = chapter,
+                displayTitle = displayTitle,
+                content = processed,
+                source = source,
+                revision = layoutGeneration + chapter.index,
+                viewportWidthPx = environment.widthPx,
+                viewportHeightPx = environment.heightPx,
+                contentPaddingLeftPx = environment.contentPaddingLeftPx,
+                contentPaddingTopPx = environment.contentPaddingTopPx,
+                contentPaddingRightPx = environment.contentPaddingRightPx,
+                contentPaddingBottomPx = environment.contentPaddingBottomPx,
+                paginationStyle = environment.style,
+                highlightRules = highlightRules,
+            )) {
+                is LegacyReaderChapterPaginationResult.Success -> {
                 wholeBookPageCoordinator.correctChapter(
                     chapterIndex = chapter.index,
-                    realPageCount = pageCount.coerceAtLeast(1),
+                    realPageCount = result.pages.size.coerceAtLeast(1),
                     layoutGeneration = layoutGeneration,
                 )
-            } finally {
-                textChapter.cancelLayout()
+                }
+                is LegacyReaderChapterPaginationResult.Unsupported -> Unit
             }
         }
     }
@@ -555,14 +581,47 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             .takeIf { it > 0 }
     }
 
-    private fun correctWholeBookPageCount(textChapter: TextChapter) {
-        wholeBookPageCoordinator.correctChapter(
-            chapterIndex = textChapter.chapter.index,
-            realPageCount = textChapter.pageSize,
-            layoutGeneration = textChapter.pageEstimateGeneration,
+    fun publishReaderPagination(snapshots: List<ReaderChapterPaginationSnapshot>) {
+        readerPaginationSnapshots = snapshots.associateBy { it.chapterIndex }
+        snapshots.forEach { snapshot ->
+            wholeBookPageCoordinator.correctChapter(
+                chapterIndex = snapshot.chapterIndex,
+                realPageCount = snapshot.pageCount,
+                layoutGeneration = snapshot.generation,
+            )
+        }
+        publishSnapshot()
+    }
+
+    fun publishReaderPaginationEnvironment(
+        widthPx: Int,
+        heightPx: Int,
+        style: ReaderAndroidPaginationStyle,
+        contentPaddingLeftPx: Int = 0,
+        contentPaddingTopPx: Int = 0,
+        contentPaddingRightPx: Int = 0,
+        contentPaddingBottomPx: Int = 0,
+    ) {
+        readerPaginationEnvironment = ReaderPaginationEnvironment(
+            widthPx = widthPx,
+            heightPx = heightPx,
+            style = style,
+            contentPaddingLeftPx = contentPaddingLeftPx,
+            contentPaddingTopPx = contentPaddingTopPx,
+            contentPaddingRightPx = contentPaddingRightPx,
+            contentPaddingBottomPx = contentPaddingBottomPx,
         )
     }
 
+    fun readerPagination(chapterIndex: Int = durChapterIndex): ReaderChapterPaginationSnapshot? =
+        readerPaginationSnapshots[chapterIndex]
+
+    val readerPaginationGeneration: Long get() = wholeBookPageCoordinator.generation
+
+    fun clearReaderPagination() {
+        readerPaginationSnapshots = emptyMap()
+    }
+
     fun upWebBook(book: Book) {
         if (book.isLocal) {
             bookSource = null
@@ -651,12 +710,38 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
     }
 
+    @Synchronized
     fun clearTextChapter() {
         clearExpiredChapterLoadingJob(true)
         clearTranslationObserverJobs()
-        prevTextChapter = null
-        curTextChapter = null
-        nextTextChapter = null
+        readerChapterInputWindow = ReaderChapterInputWindow()
+        clearReaderPagination()
+    }
+
+    @Synchronized
+    private fun publishReaderChapterInput(input: ReaderChapterInput): Boolean {
+        if (book?.bookUrl != input.book.bookUrl) return false
+        readerChapterInputWindow = when (input.chapter.index - durChapterIndex) {
+            -1 -> readerChapterInputWindow.copy(previous = input)
+            0 -> readerChapterInputWindow.copy(current = input)
+            1 -> readerChapterInputWindow.copy(next = input)
+            else -> return false
+        }
+        return true
+    }
+
+    @Synchronized
+    private fun moveReaderChapterInputNext() {
+        readerChapterInputWindow = readerChapterInputWindow.let {
+            ReaderChapterInputWindow(previous = it.current, current = it.next)
+        }
+    }
+
+    @Synchronized
+    private fun moveReaderChapterInputPrevious() {
+        readerChapterInputWindow = readerChapterInputWindow.let {
+            ReaderChapterInputWindow(current = it.previous, next = it.current)
+        }
     }
 
     private fun clearTranslationObserverJobs() {
@@ -667,12 +752,6 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             }
     }
 
-    fun clearSearchResult() {
-        curTextChapter?.clearSearchResult()
-        prevTextChapter?.clearSearchResult()
-        nextTextChapter?.clearSearchResult()
-    }
-
     fun uploadProgress(toast: Boolean = false, successAction: (() -> Unit)? = null) {
         book?.let {
             launch(IO) {
@@ -887,38 +966,25 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun moveToNextPage(): Boolean {
         prepareManualNavigation()
-        var hasNextPage = false
-        curTextChapter?.let {
-            val nextPagePos = it.getNextPageLength(durChapterPos)
-            if (nextPagePos >= 0) {
-                hasNextPage = true
-                it.getPage(durPageIndex)?.removePageAloudSpan()
-                durChapterPos = nextPagePos
-                renderCallBack?.cancelSelect()
-                renderCallBack?.upContent()
-                saveRead(true)
-                publishSnapshot()
-            }
-        }
+        val nextPagePos = readerPagination()?.nextPageStart(durChapterPos) ?: return false
+        durChapterPos = nextPagePos
+        renderCallBack?.cancelSelect()
+        renderCallBack?.upContent()
+        saveRead(true)
+        publishSnapshot()
         followReadAloudAfterManualNavigation()
-        return hasNextPage
+        return true
     }
 
     fun moveToPrevPage(): Boolean {
         prepareManualNavigation()
-        var hasPrevPage = false
-        curTextChapter?.let {
-            val prevPagePos = it.getPrevPageLength(durChapterPos)
-            if (prevPagePos >= 0) {
-                hasPrevPage = true
-                durChapterPos = prevPagePos
-                renderCallBack?.upContent()
-                saveRead(true)
-                publishSnapshot()
-            }
-        }
+        val prevPagePos = readerPagination()?.previousPageStart(durChapterPos) ?: return false
+        durChapterPos = prevPagePos
+        renderCallBack?.upContent()
+        saveRead(true)
+        publishSnapshot()
         followReadAloudAfterManualNavigation()
-        return hasPrevPage
+        return true
     }
 
     fun moveToNextChapter(upContent: Boolean, upContentInPlace: Boolean = true): Boolean {
@@ -927,10 +993,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             durChapterPos = 0
             durChapterIndex++
             clearExpiredChapterLoadingJob()
-            prevTextChapter = curTextChapter
-            curTextChapter = nextTextChapter
-            nextTextChapter = null
-            if (curTextChapter == null) {
+            moveReaderChapterInputNext()
+            if (readerChapterInputWindow.current == null) {
                 AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
                 if (upContentInPlace) renderCallBack?.upContent()
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
@@ -959,10 +1023,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             durChapterPos = 0
             durChapterIndex++
             clearExpiredChapterLoadingJob()
-            prevTextChapter = curTextChapter
-            curTextChapter = nextTextChapter
-            nextTextChapter = null
-            if (curTextChapter == null) {
+            moveReaderChapterInputNext()
+            if (readerChapterInputWindow.current == null) {
                 AppLog.putDebug("moveToNextChapter-章节未加载,开始加载")
                 if (upContentInPlace) renderCallBack?.upContentAwait()
                 loadContentAwait(durChapterIndex, upContent, resetPageOffset = false)
@@ -990,13 +1052,14 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     ): Boolean {
         prepareManualNavigation()
         if (durChapterIndex > 0) {
-            durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: Int.MAX_VALUE else 0
+            durChapterPos = if (toLast) {
+                readerPagination(durChapterIndex - 1)?.lastPageStart
+                    ?: Int.MAX_VALUE
+            } else 0
             durChapterIndex--
             clearExpiredChapterLoadingJob()
-            nextTextChapter = curTextChapter
-            curTextChapter = prevTextChapter
-            prevTextChapter = null
-            if (curTextChapter == null) {
+            moveReaderChapterInputPrevious()
+            if (readerChapterInputWindow.current == null) {
                 if (upContentInPlace) renderCallBack?.upContent()
                 loadContent(durChapterIndex, upContent, resetPageOffset = false)
             } else if (upContent && upContentInPlace) {
@@ -1015,7 +1078,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun skipToPage(index: Int, success: (() -> Unit)? = null) {
         prepareManualNavigation()
-        durChapterPos = curTextChapter?.getReadLength(index) ?: index
+        durChapterPos = readerPagination()?.pageStart(index) ?: return
         renderCallBack?.upContent {
             success?.invoke()
         }
@@ -1026,28 +1089,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun setPageIndex(index: Int) {
         prepareManualNavigation()
-        recycleRecorders(durPageIndex, index)
-        durChapterPos = curTextChapter?.getReadLength(index) ?: index
+        durChapterPos = readerPagination()?.pageStart(index) ?: return
         saveRead(true)
         curPageChanged(true)
         publishSnapshot()
     }
 
-    fun recycleRecorders(beforeIndex: Int, afterIndex: Int) {
-        if (!ReadConfig.optimizeRender) {
-            return
-        }
-        executor.execute {
-            val textChapter = curTextChapter ?: return@execute
-            if (afterIndex > beforeIndex) {
-                textChapter.getPage(afterIndex - 2)?.recycleRecorders()
-            }
-            if (afterIndex < beforeIndex) {
-                textChapter.getPage(afterIndex + 3)?.recycleRecorders()
-            }
-        }
-    }
-
     fun openChapter(
         index: Int,
         durChapterPos: Int = 0,
@@ -1080,14 +1127,14 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         preserveReadAloudPosition: Boolean = false,
     ) {
         renderCallBack?.pageChanged()
-        curTextChapter?.let {
-            if (BaseReadAloudService.isRun && it.isCompleted) {
+        readerChapterInputWindow.current?.let { input ->
+            if (BaseReadAloudService.isRun) {
                 // 页面已脱离朗读位置(用户手动导航):既不重启朗读到当前页,也不把页面拉回朗读位置
                 if (readAloudSessionStore.state.value.followReadAloudPosition) {
                     if (shouldRestartReadAloudAfterContentLoad(
                             preserveReadAloudPosition = preserveReadAloudPosition,
                             serviceChapterIndex = BaseReadAloudService.currentChapterIndex,
-                            loadedChapterIndex = it.chapter.index,
+                            loadedChapterIndex = input.chapter.index,
                         )
                     ) {
                         if (isScroll && pageChanged) {
@@ -1108,12 +1155,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     /**
      * 朗读
      */
-    fun readAloud(play: Boolean = true, startPos: Int = 0) {
+    fun readAloud(play: Boolean = true, chapterPosition: Int? = null) {
         book ?: return
-        val textChapter = curTextChapter ?: return
-        if (textChapter.isCompleted) {
-            ReadAloud.play(appCtx, play, startPos = startPos)
-        }
+        readerChapterInputWindow.current ?: return
+        ReadAloud.play(appCtx, play, chapterPosition = chapterPosition)
     }
 
     fun syncReadAloudPage(chapterIndex: Int, chapterPos: Int) {
@@ -1125,16 +1170,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     /**
-     * 把显示页定位到章内字符位置并绘制朗读高亮("回到朗读位置"跳转后调用)。
+     * 朗读位置变化后的内容重绘("回到朗读位置"跳转后调用)。
+     * 旧 View 渲染通过页内 aloudSpan 绘制朗读高亮;新渲染核心由快照驱动,
+     * 这里只负责触发一次内容重绘。
      */
     fun upTextChapterAloudSpan(chapterStart: Int) {
         if (chapterStart < 0) return
-        val textChapter = curTextChapter ?: return
-        if (textChapter.chapter.index != BaseReadAloudService.currentChapterIndex) return
-        val pageIndex = textChapter.getPageIndexByCharIndex(chapterStart)
-        if (pageIndex < 0) return
-        val aloudSpanStart = chapterStart - textChapter.getReadLength(pageIndex)
-        textChapter.getPage(pageIndex)?.upPageAloudSpan(aloudSpanStart)
         renderCallBack?.upContent(resetPageOffset = false)
     }
 
@@ -1143,33 +1184,19 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      */
     val durPageIndex: Int
         get() {
-            return curTextChapter?.getPageIndexByCharIndex(durChapterPos) ?: 0
+            return readerPagination()?.pageIndex(durChapterPos)
+                ?: 0
         }
 
     /**
      * 是否排版到了当前阅读位置
      */
-    val isLayoutAvailable inline get() = durPageIndex >= 0
+    val isLayoutAvailable inline get() =
+        readerPagination()?.pageCount?.let { it > 0 } ?: false
 
     val isScroll inline get() = pageAnim() == scrollPageAnim
 
-    val contentLoadFinish get() = curTextChapter != null || msg != null
-
-    /**
-     * chapterOnDur: 0为当前页,1为下一页,-1为上一页
-     */
-    fun textChapter(chapterOnDur: Int = 0): TextChapter? {
-        val chapter = when (chapterOnDur) {
-            0 -> curTextChapter
-            1 -> nextTextChapter
-            -1 -> prevTextChapter
-            else -> null
-        }
-        if (chapter != null && !chapter.isLayoutSizeMatch()) {
-            return null
-        }
-        return chapter
-    }
+    val contentLoadFinish get() = readerChapterInputWindow.current != null || msg != null
 
     /**
      * 导航到阅读页时提前加载, 让加载与导航动画/组合并行.
@@ -1179,8 +1206,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     fun prefetchForOpen(bookUrl: String) {
         if (callBack != null) return
         if (BaseReadAloudService.isRun) return
-        if (ChapterProvider.visibleWidth <= 0 || ChapterProvider.visibleHeight <= 0) return
-        if (book?.bookUrl == bookUrl && curTextChapter?.isCompleted == true) return
+        if (book?.bookUrl == bookUrl && readerChapterInputWindow.current != null) return
         ioScope.launch {
             val book = appDb.bookDao.getBook(bookUrl) ?: return@launch
             // 需要联网补目录、本地文件缺失或已改动的, 都交给阅读页的完整初始化流程
@@ -1230,13 +1256,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     fun loadOrUpContent(success: (() -> Unit)? = null) {
-        val curChapter = curTextChapter
-        if (curChapter == null || !curChapter.isLayoutSizeMatch()) {
+        if (readerChapterInputWindow.current == null) {
             val preserveReadAloudPosition = BaseReadAloudService.isRun &&
                     BaseReadAloudService.currentChapterIndex == durChapterIndex
-            curTextChapter = null
-            nextTextChapter = null
-            prevTextChapter = null
             loadContent(
                 durChapterIndex,
                 preserveReadAloudPosition = preserveReadAloudPosition,
@@ -1247,14 +1269,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             renderCallBack?.upContent()
             publishSnapshot()
         }
-        val nextChapter = nextTextChapter
-        if (nextChapter == null || !nextChapter.isLayoutSizeMatch()) {
-            nextTextChapter = null
+        if (readerChapterInputWindow.next == null) {
             loadContent(durChapterIndex + 1)
         }
-        val prevChapter = prevTextChapter
-        if (prevChapter == null || !prevChapter.isLayoutSizeMatch()) {
-            prevTextChapter = null
+        if (readerChapterInputWindow.previous == null) {
             loadContent(durChapterIndex - 1)
         }
     }
@@ -1541,114 +1559,49 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             },
             layoutGeneration = pageEstimateGeneration,
         )
-        val textChapter = ChapterProvider.getTextChapterAsync(
-            this, book, bookSource, chapter, displayTitle, contents, simulatedChapterSize
-        ).apply {
-            this.pageEstimateGeneration = pageEstimateGeneration
-        }
-        if (!isCurrentLocalChapter(chapter)) {
-            textChapter.cancelLayout()
-            return
-        }
+        val readerSource = ReaderChapterSourceParser.parse(
+            chapterIndex = chapter.index,
+            title = displayTitle,
+            paragraphs = contents.textList,
+            includeTitle = ReadBookConfig.titleMode != 2 || chapter.isVolume || contents.textList.isEmpty(),
+            adaptSpecialStyle = ReadConfig.adaptSpecialStyle,
+            htmlSemanticTextResolver = AndroidReaderHtmlSemanticTextResolver,
+        )
+        val readerChapterInput = ReaderChapterInput(
+            book = book,
+            bookSource = bookSource,
+            chapter = chapter,
+            displayTitle = displayTitle,
+            content = contents,
+            source = readerSource,
+            contentHash = contents.hashCode(),
+            contentProcessesHash = contents.effectiveContentProcesses.hashCode(),
+            sourceHash = readerSource.hashCode(),
+            bookSourceHash = bookSource.hashCode(),
+            pageEstimateGeneration = pageEstimateGeneration,
+        )
+        ensureActive()
+        if (!isCurrentLocalChapter(chapter) || !publishReaderChapterInput(readerChapterInput)) return
+        renderCallBack?.readerChapterInputChanged()
         when (val offset = chapter.index - durChapterIndex) {
-            0 -> curChapterLoadingLock.withLock {
-                publishTextChapter(textChapter) {
-                    curTextChapter?.cancelLayout()
-                    curTextChapter = textChapter
-                }
+            0 -> {
                 callBack?.upMenuView()
-                var available = false
-                val layoutCompleted = collectLayoutPages(textChapter) { page ->
-                    val index = page.index
-                    if (!available && page.containPos(durChapterPos)) {
-                        if (upContent) {
-                            renderCallBack?.upContent(offset, resetPageOffset)
-                        }
-                        available = true
-                    }
-                    if (upContent && isScroll) {
-                        if (max(index - 3, 0) < durPageIndex) {
-                            renderCallBack?.upContent(offset, false)
-                        }
-                    }
-                    renderCallBack?.onLayoutPageCompleted(index, page)
-                }
-                if (layoutCompleted) correctWholeBookPageCount(textChapter)
-                if (upContent) renderCallBack?.upContent(offset, !available && resetPageOffset)
+                if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
                 curPageChanged(
                     preserveReadAloudPosition = preserveReadAloudPosition,
                 )
                 renderCallBack?.contentLoadFinish()
-                // 布局全部完成后发一次:seekMax 依赖 curTextChapter.pages.size,
-                // 渲染下沉后靠快照驱动 VM 的进度/章节状态刷新
                 publishSnapshot()
             }
 
-            -1 -> prevChapterLoadingLock.withLock {
-                publishTextChapter(textChapter) {
-                    prevTextChapter?.cancelLayout()
-                    prevTextChapter = textChapter
-                }
-                if (collectLayoutPages(textChapter) {}) {
-                    correctWholeBookPageCount(textChapter)
-                }
+            -1 -> {
                 if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
             }
 
-            1 -> nextChapterLoadingLock.withLock {
-                publishTextChapter(textChapter) {
-                    nextTextChapter?.cancelLayout()
-                    nextTextChapter = textChapter
-                }
-                val layoutCompleted = collectLayoutPages(textChapter) { page ->
-                    if (page.index > 1) return@collectLayoutPages
-                    if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
-                }
-                if (layoutCompleted) correctWholeBookPageCount(textChapter)
-            }
-
-            else -> textChapter.cancelLayout()
-        }
-    }
-
-    /**
-     * 发布排版好的章节.
-     * 阅读页正在显示时切到主线程赋值, 保持原有的线程约束, 不与正在进行的帧交错;
-     * 阅读页还没显示时(导航期间的预加载)直接赋值: 此时主线程正被导航动画和
-     * 阅读页组合占满, 等一次主线程派发要几百毫秒, 预加载就白做了.
-     * 三个章节字段都是 @Volatile, 直接赋值对随后显示的阅读页可见.
-     */
-    private suspend fun publishTextChapter(textChapter: TextChapter, assign: () -> Unit) {
-        if (!isUiActive) {
-            currentCoroutineContext().ensureActive()
-            assign()
-        } else {
-            withContext(Main) {
-                currentCoroutineContext().ensureActive()
-                assign()
-            }
-        }
-    }
-
-    /**
-     * Safely collect pages from a TextChapterLayout's channel with timeout protection.
-     * Prevents indefinite blocking if the layout job hangs without closing the channel.
-     */
-    private suspend fun collectLayoutPages(
-        textChapter: TextChapter,
-        onPage: (TextPage) -> Unit,
-    ): Boolean {
-        return try {
-            withTimeout(30_000L) {
-                for (page in textChapter.layoutChannel) {
-                    ensureActive()
-                    onPage(page)
-                }
+            1 -> {
+                if (upContent) renderCallBack?.upContent(offset, resetPageOffset)
             }
-            true
-        } catch (_: TimeoutCancellationException) {
-            AppLog.put("Layout channel timeout for chapter ${textChapter.chapter.index}")
-            false
+            else -> Unit
         }
     }
 
@@ -1692,7 +1645,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 chapterSize = cList.size
                 simulatedChapterSize = book.simulatedTotalChapterNum()
                 publishSnapshot()
-                nextTextChapter ?: loadContent(durChapterIndex + 1)
+                if (readerChapterInputWindow.next == null) loadContent(durChapterIndex + 1)
             }
         }
     }
@@ -1783,8 +1736,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         if (newBook.isSameNameAuthor(book)) {
             // 本地 TXT 改目录规则后,旧目录中的章节序号已经不再能表示同一段正文。
             // 在替换目录前保留当前章节的文件偏移,随后在新目录中重新定位,避免目录
-            // 已更新而阅读页仍显示旧 TextChapter 的标题和序号。
-            val currentChapterStart = curTextChapter?.chapter?.start
+            // 已更新而阅读页仍显示旧章节标题和序号。
+            val currentChapterStart = readerChapterInputWindow.current?.chapter?.start
             book = newBook
             chapterSize = newBook.totalChapterNum
             simulatedChapterSize = newBook.simulatedTotalChapterNum()
@@ -1873,9 +1826,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      */
     fun registerRender(cb: ReaderRenderCallback) {
         renderCallBack = cb
-        if (curTextChapter != null) {
-            cb.upContent()
-        }
+        if (readerChapterInputWindow.current != null) cb.readerChapterInputChanged()
     }
 
     /**
@@ -1912,7 +1863,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      * 渲染子集回调(Track B):指令式渲染协议——重绘、分页、动画、选择取消、
      * 排版进度。**只应由 UI 层的渲染控制器实现**,不得穿过 ViewModel/业务层。
      */
-    interface ReaderRenderCallback : LayoutProgressListener {
+    interface ReaderRenderCallback {
+        fun readerChapterInputChanged() = Unit
+
         fun upContent(
             relativePosition: Int = 0,
             resetPageOffset: Boolean = true,

+ 13 - 3
app/src/main/java/io/legado/app/model/ReadSessionState.kt

@@ -8,6 +8,11 @@ import io.legado.app.utils.getMeanColor
 
 /** 仅在当前阅读会话内有效的渲染资源,不参与持久化。 */
 object ReadSessionState {
+    data class BackgroundSnapshot(
+        val drawable: Drawable?,
+        val meanColor: Int,
+    )
+
     var isComic: Boolean = false
         internal set
 
@@ -23,13 +28,18 @@ object ReadSessionState {
     var backgroundMeanColor: Int = 0
         private set
 
-    fun updateBackground(width: Int, height: Int) {
+    fun loadBackground(width: Int, height: Int): BackgroundSnapshot {
         val drawable = ReadBookConfig.durConfig.curBgDrawable(width, height)
-        backgroundMeanColor = when (drawable) {
+        val meanColor = when (drawable) {
             is BitmapDrawable -> drawable.bitmap?.getMeanColor() ?: 0
             is ColorDrawable -> drawable.color
             else -> 0
         }
-        background = drawable
+        return BackgroundSnapshot(drawable, meanColor)
+    }
+
+    fun applyBackground(snapshot: BackgroundSnapshot) {
+        background = snapshot.drawable
+        backgroundMeanColor = snapshot.meanColor
     }
 }

+ 29 - 0
app/src/main/java/io/legado/app/model/reader/ReaderChapterInput.kt

@@ -0,0 +1,29 @@
+package io.legado.app.model.reader
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookSource
+import io.legado.app.help.book.BookContent
+import io.legado.app.feature.reader.core.source.ReaderChapterSource
+
+/** Processed chapter input published before any View-era page layout is consumed. */
+data class ReaderChapterInput(
+    val book: Book,
+    val bookSource: BookSource?,
+    val chapter: BookChapter,
+    val displayTitle: String,
+    val content: BookContent,
+    val source: ReaderChapterSource,
+    /** Precomputed off the main thread; used to deduplicate Compose pagination requests. */
+    val contentHash: Int,
+    val contentProcessesHash: Int,
+    val sourceHash: Int,
+    val bookSourceHash: Int,
+    val pageEstimateGeneration: Long,
+)
+
+data class ReaderChapterInputWindow(
+    val previous: ReaderChapterInput? = null,
+    val current: ReaderChapterInput? = null,
+    val next: ReaderChapterInput? = null,
+)

+ 91 - 79
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -38,13 +38,15 @@ import io.legado.app.constant.Status
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
 import io.legado.app.domain.model.readaloud.SpeechPlanItem
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackQueue
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackInfo
+import io.legado.app.domain.model.readaloud.resolveReadAloudStartPosition
+import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
-import io.legado.app.help.readaloud.segment.toCanonicalSpeechParagraphs
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.ui.config.readConfig.ReadConfig
@@ -56,7 +58,6 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
 import io.legado.app.receiver.MediaButtonReceiver
-import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.activityPendingIntent
@@ -188,7 +189,7 @@ abstract class BaseReadAloudService : BaseService(),
     internal var playbackCursor: ReadAloudPlaybackCursor? = null
     internal var nowSpeak: Int = 0
     internal var readAloudNumber: Int = 0
-    internal var textChapter: TextChapter? = null
+    internal var readerReadAloudChapter: ReaderReadAloudChapter? = null
     internal var pageIndex = 0
     private var needResumeOnAudioFocusGain = false
     private var needResumeOnCallStateIdle = false
@@ -215,6 +216,10 @@ abstract class BaseReadAloudService : BaseService(),
     var paragraphStartPos = 0
     var readAloudByPage = false
         private set
+
+    /** 当前朗读段在章节语义文本中的绝对起始位置;页内切段不引入换行符,进度必须以它为准 */
+    protected fun paragraphChapterPositionAt(index: Int): Int? =
+        readerReadAloudChapter?.paragraphs(readAloudByPage)?.getOrNull(index)?.chapterPosition
     protected open val useSpeechPlaybackQueue: Boolean = false
     protected val hasSpeechPlaybackQueue: Boolean
         get() = useSpeechPlaybackQueue && !playbackQueue.isEmpty
@@ -267,7 +272,8 @@ abstract class BaseReadAloudService : BaseService(),
             val play = it.getBoolean("play")
             val pageIndex = it.getInt("pageIndex")
             val startPos = it.getInt("startPos")
-            newReadAloud(play, pageIndex, startPos)
+            val chapterPosition = it.getInt("chapterPosition", -1).takeIf { position -> position >= 0 }
+            newReadAloud(play, pageIndex, startPos, chapterPosition)
         }
         lifecycleScope.launch {
             merge(
@@ -318,13 +324,14 @@ abstract class BaseReadAloudService : BaseService(),
             IntentAction.play -> newReadAloud(
                 intent.getBooleanExtra("play", true),
                 intent.getIntExtra("pageIndex", ReadBook.durPageIndex),
-                intent.getIntExtra("startPos", 0)
+                intent.getIntExtra("startPos", 0),
+                intent.getIntExtra("chapterPosition", -1).takeIf { it >= 0 },
             )
 
             IntentAction.pause -> pauseReadAloud()
             IntentAction.resume -> resumeReadAloud()
             IntentAction.upTtsSpeechRate -> upSpeechRate(true)
-            IntentAction.syncReadAloudLayout -> syncTextChapterLayout()
+            IntentAction.syncReadAloudLayout -> syncReaderLayout()
             IntentAction.prevParagraph -> prevP()
             IntentAction.nextParagraph -> nextP()
             IntentAction.prev -> prevChapter()
@@ -336,26 +343,46 @@ abstract class BaseReadAloudService : BaseService(),
         return super.onStartCommand(intent, flags, startId)
     }
 
-    private fun newReadAloud(play: Boolean, pageIndex: Int, startPos: Int) {
+    private fun newReadAloud(
+        play: Boolean,
+        requestedPageIndex: Int,
+        requestedStartPos: Int,
+        requestedChapterPosition: Int?,
+    ) {
         // 每次"从指定位置开始朗读"都是新会话:恢复页面跟随朗读(手动脱离后点"从此处朗读"等)
         sessionStore.restoreReadAloudFollow()
         clearFinishChapterTimerIfChapterChanged(ReadBook.durChapterIndex)
         val generation = ++prepareReadAloudGeneration
         prepareReadAloudJob?.cancel()
         prepareReadAloudJob = execute(executeContext = IO) {
-            val preparedChapter = ReadBook.curTextChapter ?: return@execute
-            if (!preparedChapter.isCompleted) {
+            val input = ReadBook.readerChapterInputWindow.current ?: return@execute
+            val pagination = ReadBook.readerPagination(input.chapter.index) ?: run {
+                AppLog.put("启动朗读失败:章节分页未完成 chapterIndex=${input.chapter.index}")
                 return@execute
             }
+            val preparedChapter = ReaderReadAloudChapter.create(
+                chapterIndex = input.chapter.index,
+                title = input.displayTitle,
+                semanticContent = input.source.semanticContent,
+                pageStarts = pagination.pageStarts,
+            )
+            val start = resolveReadAloudStartPosition(
+                requestedPageIndex = requestedPageIndex,
+                requestedOffsetInPage = requestedStartPos,
+                requestedChapterPosition = requestedChapterPosition,
+                pageIndexAt = preparedChapter::pageIndexAt,
+                pageStart = preparedChapter::pageStart,
+            )
+            val pageIndex = start.pageIndex
+            val startPos = start.offsetInPage
             val preparedReadAloudByPage = ReadConfig.readAloudByPage
-            var preparedReadAloudNumber = preparedChapter.getReadLength(pageIndex) + startPos
-            var preparedContentList = preparedChapter.getNeedReadAloud(0, preparedReadAloudByPage, 0)
-                .split("\n")
-                .filter { it.isNotEmpty() }
+            var preparedReadAloudNumber = preparedChapter.pageStart(pageIndex) + startPos
+            var preparedContentList = preparedChapter.paragraphs(preparedReadAloudByPage)
+                .map { it.text.replace(Regex("[袮祢꧁]"), " ") }
             val preparedSpeechPlan = buildSpeechPlan(
                 bookUrl = ReadBook.book?.bookUrl.orEmpty(),
                 chapterIndex = ReadBook.durChapterIndex,
-                textChapter = preparedChapter,
+                paragraphs = preparedChapter.canonicalSpeechParagraphs(),
             )
             if (generation != prepareReadAloudGeneration) return@execute
             val preparedPlaybackQueue = runCatching {
@@ -365,19 +392,12 @@ abstract class BaseReadAloudService : BaseService(),
             }.getOrDefault(ReadAloudPlaybackQueue.Empty)
             var preparedPlaybackCursor = preparedPlaybackQueue.cursorAt(preparedReadAloudNumber)
             var pos = startPos
-            val page = preparedChapter.getPage(pageIndex) ?: return@execute
-            if (pos > 0) {
-                for (paragraph in page.paragraphs) {
-                    val tmp = pos - paragraph.length - 1
-                    if (tmp < 0) break
-                    pos = tmp
-                }
-            }
+            val preparedParagraphs = preparedChapter.paragraphs(preparedReadAloudByPage)
             val usePreparedPlaybackQueue = useSpeechPlaybackQueue && !preparedPlaybackQueue.isEmpty
-            var preparedNowSpeak = preparedChapter.getParagraphNumAtOrAfter(
+            var preparedNowSpeak = preparedChapter.paragraphIndexAtOrAfter(
                 preparedReadAloudNumber + 1,
                 preparedReadAloudByPage,
-            ) - 1
+            )
             if (!usePreparedPlaybackQueue && preparedNowSpeak !in preparedContentList.indices) {
                 AppLog.put(
                     "启动朗读失败:无法定位朗读段落 position=$preparedReadAloudNumber " +
@@ -386,17 +406,18 @@ abstract class BaseReadAloudService : BaseService(),
                 return@execute
             }
             val moveToLast = toLast
-            if (!usePreparedPlaybackQueue && !preparedReadAloudByPage && startPos == 0 && !moveToLast) {
-                pos = page.chapterPosition -
-                        preparedChapter.paragraphs[preparedNowSpeak].chapterPosition
-            }
             if (moveToLast) {
-                preparedReadAloudNumber = preparedChapter.getLastParagraphPosition()
+                preparedReadAloudNumber = preparedParagraphs.last().chapterPosition
                 preparedNowSpeak = preparedContentList.lastIndex
-                if (page.paragraphs.size == 1) {
-                    pos = page.chapterPosition -
-                            preparedChapter.paragraphs[preparedNowSpeak].chapterPosition
-                }
+                pos = 0
+            }
+            // startPos 是页内偏移,需换算为目标朗读段内的偏移
+            if (!usePreparedPlaybackQueue && !moveToLast &&
+                preparedNowSpeak in preparedParagraphs.indices
+            ) {
+                val target = preparedParagraphs[preparedNowSpeak]
+                pos = (preparedReadAloudNumber - target.chapterPosition)
+                    .coerceIn(0, target.text.length)
             }
             var preparedParagraphStartPos = pos
             if (usePreparedPlaybackQueue) {
@@ -410,7 +431,7 @@ abstract class BaseReadAloudService : BaseService(),
             }
             if (generation != prepareReadAloudGeneration) return@execute
             this@BaseReadAloudService.pageIndex = pageIndex
-            textChapter = preparedChapter
+            readerReadAloudChapter = preparedChapter
             readAloudByPage = preparedReadAloudByPage
             contentList = preparedContentList
             speechPlan = preparedSpeechPlan
@@ -435,7 +456,7 @@ abstract class BaseReadAloudService : BaseService(),
     protected suspend fun buildSpeechPlan(
         bookUrl: String,
         chapterIndex: Int,
-        textChapter: TextChapter,
+        paragraphs: List<CanonicalSpeechParagraph>,
     ): List<SpeechPlanItem> {
         if (bookUrl.isEmpty() || !ReadConfig.useMultiSpeaker) return emptyList()
         val prepareSpeechPlan: PrepareChapterSpeechPlanUseCase =
@@ -444,7 +465,7 @@ abstract class BaseReadAloudService : BaseService(),
             prepareSpeechPlan(
                 bookUrl = bookUrl,
                 chapterIndex = chapterIndex,
-                paragraphs = textChapter.toCanonicalSpeechParagraphs(),
+                paragraphs = paragraphs,
                 analysisMode = SpeechAnalysisMode.fromStorage(ReadConfig.speechAnalysisMode),
                 useMultiSpeaker = ReadConfig.useMultiSpeaker,
             )
@@ -527,9 +548,7 @@ abstract class BaseReadAloudService : BaseService(),
         if (hasSpeechPlaybackQueue) {
             playbackQueue.cursorAt(chapterPosition)?.let(::publishPlaybackInfo)
         } else {
-            val chapterLength = textChapter?.paragraphs?.lastOrNull()?.let { paragraph ->
-                paragraph.chapterPosition + paragraph.text.length
-            } ?: chapterPosition
+            val chapterLength = readerReadAloudChapter?.chapterLength ?: chapterPosition
             sessionStore.updatePlayback(
                 ReadAloudPlaybackInfo(
                     chapterPosition = chapterPosition,
@@ -544,7 +563,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     protected fun updateReadAloudProgressSnapshot(progress: Int) {
-        currentChapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
+        currentChapterIndex = readerReadAloudChapter?.chapterIndex ?: currentChapterIndex
         val newProgress = progress.coerceAtLeast(0)
         if (newProgress < currentProgress) {
             // 进度回退(上一段/上一章等), 重置媒体进度锚点, 允许进度条跟随回退
@@ -554,12 +573,12 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     protected fun moveToReadAloudPage(chapterPosition: Int): Boolean {
-        val chapter = textChapter ?: return false
+        val chapter = readerReadAloudChapter ?: return false
         val targetPageIndex = findReadAloudPageIndex(
             currentPageIndex = pageIndex,
             chapterPosition = chapterPosition,
-            pageCount = chapter.pageSize,
-            pageStart = chapter::getReadLength,
+            pageCount = chapter.pageCount,
+            pageStart = chapter::pageStart,
         )
         if (targetPageIndex == pageIndex) return false
         // 页面脱离朗读位置(用户手动翻页)后不再驱动可见页面,仅推进朗读内部页游标
@@ -573,22 +592,24 @@ abstract class BaseReadAloudService : BaseService(),
         return true
     }
 
-    private fun syncTextChapterLayout() {
-        val latestChapter = ReadBook.curTextChapter ?: return
-        val serviceChapter = textChapter ?: return
-        if (!latestChapter.isCompleted || latestChapter.chapter.index != serviceChapter.chapter.index) {
-            return
-        }
-        val latestPageIndex = latestChapter.getPageIndexByCharIndex(
-            (currentProgress - 1).coerceAtLeast(0)
+    private fun syncReaderLayout() {
+        val input = ReadBook.readerChapterInputWindow.current ?: return
+        val serviceChapter = readerReadAloudChapter ?: return
+        if (input.chapter.index != serviceChapter.chapterIndex) return
+        val pagination = ReadBook.readerPagination(input.chapter.index) ?: return
+        val latestChapter = ReaderReadAloudChapter.create(
+            chapterIndex = input.chapter.index,
+            title = input.displayTitle,
+            semanticContent = input.source.semanticContent,
+            pageStarts = pagination.pageStarts,
         )
-        if (latestPageIndex < 0) return
-        textChapter = latestChapter
+        val latestPageIndex = latestChapter.pageIndexAt((currentProgress - 1).coerceAtLeast(0))
+        readerReadAloudChapter = latestChapter
         pageIndex = latestPageIndex
         if (sessionStore.state.value.followReadAloudPosition) {
             ReadBook.syncReadAloudPage(
-                chapterIndex = latestChapter.chapter.index,
-                chapterPos = latestChapter.getReadLength(latestPageIndex),
+                chapterIndex = latestChapter.chapterIndex,
+                chapterPos = latestChapter.pageStart(latestPageIndex),
             )
         }
         upTtsProgress(currentProgress)
@@ -623,12 +644,9 @@ abstract class BaseReadAloudService : BaseService(),
                 withSpeechNavigation { ReadBook.moveToPrevChapter(true) }
                 return
             }
-            textChapter?.let {
-                if (readAloudByPage) {
-                    val paragraphs = it.getParagraphs(true)
-                    if (!paragraphs[nowSpeak].isParagraphEnd) readAloudNumber++
-                }
-                if (readAloudNumber < it.getReadLength(pageIndex)) {
+            readerReadAloudChapter?.let {
+                paragraphChapterPositionAt(nowSpeak)?.let { position -> readAloudNumber = position }
+                if (readAloudNumber < it.pageStart(pageIndex)) {
                     pageIndex--
                     withSpeechNavigation { ReadBook.moveToPrevPage() }
                 }
@@ -657,13 +675,10 @@ abstract class BaseReadAloudService : BaseService(),
             readAloudNumber += contentList[nowSpeak].length.plus(1) - paragraphStartPos
             paragraphStartPos = 0
             nowSpeak++
-            textChapter?.let {
-                if (readAloudByPage) {
-                    val paragraphs = it.getParagraphs(true)
-                    if (!paragraphs[nowSpeak].isParagraphEnd) readAloudNumber--
-                }
-                if (pageIndex + 1 < it.pageSize
-                    && readAloudNumber >= it.getReadLength(pageIndex + 1)
+            readerReadAloudChapter?.let {
+                paragraphChapterPositionAt(nowSpeak)?.let { position -> readAloudNumber = position }
+                if (pageIndex + 1 < it.pageCount
+                    && readAloudNumber >= it.pageStart(pageIndex + 1)
                 ) {
                     pageIndex++
                     withSpeechNavigation { ReadBook.moveToNextPage() }
@@ -684,9 +699,9 @@ abstract class BaseReadAloudService : BaseService(),
         paragraphStartPos = cursor.offset
         readAloudNumber = cue.chapterStart
         publishPlaybackInfo(cursor)
-        textChapter?.let { chapter ->
+        readerReadAloudChapter?.let { chapter ->
             val targetPosition = cue.chapterStart + cursor.offset
-            val targetPage = chapter.getPageIndexByCharIndex(targetPosition)
+            val targetPage = chapter.pageIndexAt(targetPosition)
             while (pageIndex < targetPage) {
                 pageIndex++
                 withSpeechNavigation { ReadBook.moveToNextPage() }
@@ -877,10 +892,7 @@ abstract class BaseReadAloudService : BaseService(),
         return estimatedReadAloudTimeMs(chapterLength, charsPerSecond)
     }
 
-    private fun currentChapterLength(): Int =
-        textChapter?.paragraphs?.lastOrNull()?.let { paragraph ->
-            paragraph.chapterPosition + paragraph.text.length
-        } ?: 0
+    private fun currentChapterLength(): Int = readerReadAloudChapter?.chapterLength ?: 0
 
     private fun currentCharsPerSecond(): Float =
         (ESTIMATED_CHARS_PER_SECOND * currentSpeechRate).coerceAtLeast(0.1f)
@@ -898,7 +910,7 @@ abstract class BaseReadAloudService : BaseService(),
         val metadata = MediaMetadataCompat.Builder()
             .putBitmap(MediaMetadataCompat.METADATA_KEY_ART, cover)
             .putText(MediaMetadataCompat.METADATA_KEY_TITLE, ReadBook.book?.name ?: "")
-            .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, textChapter?.title ?: "")
+            .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, readerReadAloudChapter?.title ?: "")
             .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, ReadBook.book?.author ?: "")
             .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentContent ?: "")
             .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, mediaProgressDurationMs())
@@ -1089,7 +1101,7 @@ abstract class BaseReadAloudService : BaseService(),
             else -> getString(R.string.read_aloud_t)
         }
         nTitle += ": ${ReadBook.book?.name}"
-        var nSubtitle = ReadBook.curTextChapter?.title
+        var nSubtitle = readerReadAloudChapter?.title
         if (nSubtitle.isNullOrBlank())
             nSubtitle = getString(R.string.read_aloud_s)
         val builder = NotificationCompat
@@ -1167,7 +1179,7 @@ abstract class BaseReadAloudService : BaseService(),
         val nextLabel = getString(
             if (navigateByChapter) R.string.next_chapter else R.string.next_sentence
         )
-        val chapterTitle = ReadBook.curTextChapter?.title
+        val chapterTitle = readerReadAloudChapter?.title
             ?.takeIf { it.isNotBlank() }
             ?: getString(R.string.read_aloud_s)
         return NotificationCompat.Builder(this, AppConst.channelIdReadAloud)
@@ -1246,7 +1258,7 @@ abstract class BaseReadAloudService : BaseService(),
     open fun nextChapter() {
         clearFinishChapterTimer()
         ReadBook.upReadTime()
-        AppLog.putDebug("${ReadBook.curTextChapter?.chapter?.title} 朗读结束跳转下一章并朗读")
+        AppLog.putDebug("${readerReadAloudChapter?.title} 朗读结束跳转下一章并朗读")
         resumeReadAloudInternal()
         if (!withSpeechNavigation { ReadBook.moveToNextChapter(true) }) {
             stopSelf()
@@ -1256,7 +1268,7 @@ abstract class BaseReadAloudService : BaseService(),
     /** Handles a playback engine's natural chapter boundary atomically with timer expiry. */
     protected fun completeCurrentChapter() {
         synchronized(finishChapterTimerLock) {
-            val chapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
+            val chapterIndex = readerReadAloudChapter?.chapterIndex ?: currentChapterIndex
             val decision = decideChapterCompletion(
                 durChapterIndex = ReadBook.durChapterIndex,
                 finishedChapterIndex = chapterIndex,

+ 41 - 43
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -47,6 +47,9 @@ import io.legado.app.domain.model.settings.OtherSettings
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.domain.model.settings.ReadSettings
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
+import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
+import io.legado.app.feature.reader.platform.AndroidReaderHtmlSemanticTextResolver
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.coroutine.Coroutine
@@ -60,8 +63,6 @@ import io.legado.app.help.readaloud.playback.SystemTtsFileSynthesizer
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.analyzeRule.AnalyzeUrl
-import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.GSON
 import io.legado.app.utils.MD5Utils
@@ -69,7 +70,6 @@ import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.servicePendingIntent
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.async
@@ -130,7 +130,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         ((httpTts?.speed ?: DEFAULT_TTS_SPEED) + 5) / 10f
 
     private data class PreDownloadChapter(
-        val textChapter: TextChapter,
+        val chapterTitle: String,
         val queue: ReadAloudPlaybackQueue,
         val contentList: List<String>,
     )
@@ -160,6 +160,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     private val loadErrorHandlingPolicy by lazy {
         CustomLoadErrorHandlingPolicy()
     }
+    private var speechRate: Int = speechRatePlay + 5
     private var downloadTask: Coroutine<*>? = null
     private var preDownloadJob: Job? = null
     private var playIndexJob: Job? = null
@@ -236,6 +237,8 @@ class HttpReadAloudService : BaseReadAloudService(),
             return
         }
         readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
+        // 页内切段不引入换行符,累加会漂移,用段落绝对位置重算
+        paragraphChapterPositionAt(nowSpeak + 1)?.let { readAloudNumber = it }
         paragraphStartPos = 0
         if (nowSpeak < contentList.lastIndex) {
             nowSpeak++
@@ -394,35 +397,34 @@ class HttpReadAloudService : BaseReadAloudService(),
             content,
             includeTitle = false,
         )
-        val textChapter = ChapterProvider.getTextChapterAsync(
-            CoroutineScope(currentCoroutineContext()),
-            book,
-            // 与本函数其余处一致地跟随 ReadBook 当前会话;这里传的就是改造前排版协程
-            // 自己去读的那个值,行为不变
-            ReadBook.bookSource,
-            chapter,
-            displayTitle,
-            processedContent,
-            ReadBook.simulatedChapterSize,
+        val source = ReaderChapterSourceParser.parse(
+            chapterIndex = chapter.index,
+            title = displayTitle,
+            paragraphs = processedContent.textList,
+            includeTitle = false,
+            adaptSpecialStyle = readSettings.adaptSpecialStyle,
+            htmlSemanticTextResolver = AndroidReaderHtmlSemanticTextResolver,
+        )
+        val readAloudChapter = ReaderReadAloudChapter.create(
+            chapterIndex = chapter.index,
+            title = displayTitle,
+            semanticContent = source.semanticContent,
+            pageStarts = ReadBook.readerPagination(chapter.index)?.pageStarts.orEmpty(),
         )
-        for (ignored in textChapter.layoutChannel) {
-            currentCoroutineContext().ensureActive()
-        }
         val plan = buildSpeechPlan(
             bookUrl = book.bookUrl,
             chapterIndex = chapter.index,
-            textChapter = textChapter,
+            paragraphs = readAloudChapter.canonicalSpeechParagraphs(),
         )
         val queue = runCatching { ReadAloudPlaybackQueue.from(plan) }
             .getOrDefault(ReadAloudPlaybackQueue.Empty)
         val contentList = if (!queue.isEmpty) {
             queue.cues.map { it.text }
         } else {
-            textChapter.getNeedReadAloud(0, readAloudSettings.readAloudByPage, 0)
-                .split("\n")
-                .filter { it.isNotEmpty() }
+            readAloudChapter.paragraphs(readAloudSettings.readAloudByPage)
+                .map { it.text.replace(Regex("[袮祢꧁]"), " ") }
         }
-        return PreDownloadChapter(textChapter, queue, contentList)
+        return PreDownloadChapter(displayTitle, queue, contentList)
     }
 
     private suspend fun preDownloadAudios(httpTts: HttpTTS) {
@@ -474,12 +476,12 @@ class HttpReadAloudService : BaseReadAloudService(),
                     val cue = prepared.queue.cues.getOrNull(index)
                     val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
                     val fileName = md5SpeakFileName(
-                        content, prepared.textChapter, sourceKey = sourceKey,
+                        content, prepared.chapterTitle, sourceKey = sourceKey,
                     )
                     if (hasSpeakFile(fileName)) return@async
 
                     val success = synthesizeSingleCueWithRetry(
-                        routedVoice, cue, content, prepared.textChapter, httpTts,
+                        routedVoice, cue, content, prepared.chapterTitle, httpTts,
                     )
                     if (!success) {
                         createSilentSound(fileName)
@@ -501,14 +503,14 @@ class HttpReadAloudService : BaseReadAloudService(),
         routedVoice: ReadAloudVoice,
         cue: io.legado.app.domain.model.readaloud.ReadAloudPlaybackCue?,
         content: String,
-        textChapter: TextChapter?,
+        chapterTitle: String,
         httpTts: HttpTTS,
     ): Boolean {
-        if (synthesizeSingleCue(routedVoice, cue, content, textChapter, httpTts)) {
+        if (synthesizeSingleCue(routedVoice, cue, content, chapterTitle, httpTts)) {
             return true
         }
         delay(500)
-        return synthesizeSingleCue(routedVoice, cue, content, textChapter, httpTts)
+        return synthesizeSingleCue(routedVoice, cue, content, chapterTitle, httpTts)
     }
 
     /**
@@ -518,14 +520,11 @@ class HttpReadAloudService : BaseReadAloudService(),
         routedVoice: ReadAloudVoice,
         cue: io.legado.app.domain.model.readaloud.ReadAloudPlaybackCue?,
         content: String,
-        textChapter: TextChapter?,
+        chapterTitle: String,
         httpTts: HttpTTS,
     ): Boolean {
         val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
-        val itemHttpTts = routedVoice.engineId.toLongOrNull()
-            ?.let(appDb.httpTTSDao::get) ?: httpTts
-        val fileName =
-            md5SpeakFileName(content, textChapter, httpTts = itemHttpTts, sourceKey = sourceKey)
+        val fileName = md5SpeakFileName(content, chapterTitle, sourceKey = sourceKey)
         val speakText = content.replace(AppPattern.notReadAloudRegex, "")
         if (speakText.isEmpty()) {
             createSilentSound(fileName)
@@ -544,6 +543,8 @@ class HttpReadAloudService : BaseReadAloudService(),
                 }
 
                 ReadAloudVoice.ENGINE_HTTP -> {
+                    val itemHttpTts = routedVoice.engineId.toLongOrNull()
+                        ?.let(appDb.httpTTSDao::get) ?: httpTts
                     val inputStream = getSpeakStream(itemHttpTts, speakText)
                     if (inputStream != null) {
                         createSpeakFile(fileName, inputStream)
@@ -744,11 +745,11 @@ class HttpReadAloudService : BaseReadAloudService(),
                     if (routedVoice.engineType == ReadAloudVoice.ENGINE_CLOUD) {
                         val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
                         val fileName = md5SpeakFileName(
-                            content, prepared.textChapter, sourceKey = sourceKey,
+                            content, prepared.chapterTitle, sourceKey = sourceKey,
                         )
                         if (hasSpeakFile(fileName)) return@async
                         val success = synthesizeSingleCueWithRetry(
-                            routedVoice, cue, content, prepared.textChapter, httpTts,
+                            routedVoice, cue, content, prepared.chapterTitle, httpTts,
                         )
                         if (!success) {
                             createSilentSound(fileName)
@@ -758,7 +759,7 @@ class HttpReadAloudService : BaseReadAloudService(),
                         val speakText = content.replace(AppPattern.notReadAloudRegex, "")
                         val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
                         val fileName = md5SpeakFileName(
-                            content, prepared.textChapter, sourceKey = sourceKey,
+                            content, prepared.chapterTitle, sourceKey = sourceKey,
                         )
                         val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
                         val downloader = createDownloader(dataSourceFactory, fileName)
@@ -899,15 +900,12 @@ class HttpReadAloudService : BaseReadAloudService(),
      */
     private fun md5SpeakFileName(
         content: String,
-        textChapter: TextChapter? = this.textChapter,
+        chapterTitle: String = readerReadAloudChapter?.title.orEmpty(),
         httpTts: HttpTTS? = ReadAloud.httpTTS,
         sourceKey: String = httpTts?.url.orEmpty(),
     ): String {
-        val titleToUse = textChapter?.chapter?.title ?: ""
-        return MD5Utils.md5Encode16(titleToUse) + "_" +
-                MD5Utils.md5Encode16(
-                    "$sourceKey-|-${(httpTts?.speed ?: DEFAULT_TTS_SPEED) + 5}-|-$content"
-                )
+        return MD5Utils.md5Encode16(chapterTitle) + "_" +
+                MD5Utils.md5Encode16("$sourceKey-|-$speechRate-|-$content")
     }
 
     private fun Long?.orZero(): Long = this ?: 0L
@@ -1035,7 +1033,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         val keepTime = readAloudSettings.audioCacheCleanTime * 60 * 1000L
         // 只有当时间大于0时,才需要保护当前章节。如果为0,说明用户想彻底不留缓存。
         val protectCurrentChapter = keepTime > 0
-        val titleMd5 = if (protectCurrentChapter) MD5Utils.md5Encode16(this.textChapter?.chapter?.title ?: "") else ""
+        val titleMd5 = if (protectCurrentChapter) MD5Utils.md5Encode16(readerReadAloudChapter?.title.orEmpty()) else ""
 
         FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach {
             val isSilentSound = it.length() == 2160L
@@ -1082,7 +1080,7 @@ class HttpReadAloudService : BaseReadAloudService(),
 
     private fun upPlayPos() {
         playIndexJob?.cancel()
-        if (textChapter == null) return
+        if (readerReadAloudChapter == null) return
         playIndexJob = lifecycleScope.launch {
             if (exoPlayer.duration <= 0) {
                 upTtsProgress(readAloudNumber + 1)

+ 6 - 4
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -189,7 +189,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
             } else {
                 // 无间隔模式:保持原有的队列式连续播放,确保无缝衔接
                 LogUtils.d(TAG, "朗读列表大小 ${contentList.size}")
-                LogUtils.d(TAG, "朗读页数 ${textChapter?.pageSize}")
+                LogUtils.d(TAG, "朗读页数 ${readerReadAloudChapter?.pageCount}")
                 val tts = textToSpeech ?: throw NoStackTraceException("tts is null")
                 val contentList = contentList
                 var isAddedText = false
@@ -345,12 +345,12 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             utteranceStartPos = paragraphStartPos
             utteranceStartReadAloudNumber = readAloudNumber
-            textChapter?.let {
+            readerReadAloudChapter?.let {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                     nextParagraph(naturalCompletion = true)
                 }
-                if (pageIndex + 1 < it.pageSize
-                    && readAloudNumber + 1 > it.getReadLength(pageIndex + 1)
+                if (pageIndex + 1 < it.pageCount
+                    && readAloudNumber + 1 > it.pageStart(pageIndex + 1)
                 ) {
                     pageIndex++
                     // This is the TTS engine advancing across a page boundary, not a user turn.
@@ -425,6 +425,8 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                     return
                 }
             } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex))
+            // 页内切段不引入换行符,累加会漂移,用段落绝对位置重算
+            paragraphChapterPositionAt(nowSpeak)?.let { readAloudNumber = it }
         }
 
         @Deprecated("Deprecated in Java")

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/BookmarkBadgeDelegate.kt

@@ -35,7 +35,7 @@ class BookmarkBadgeDelegate(
                 path
             }.onSuccess { path ->
                 readSettingsRepository.preferences.first { it.bookmarkBadgeImage == path }
-                emitEffect(ReadBookEffect.UpdateReadViewConfig(setOf(ConfigUpdateAction.UpdateStyle)))
+                emitEffect(ReadBookEffect.UpdateReaderConfig(setOf(ConfigUpdateAction.UpdateStyle)))
                 emitEffect(ReadBookEffect.ShowToast(context.getString(R.string.success)))
             }.onFailure { throwable ->
                 AppLog.put("选择书签角标失败", throwable)
@@ -56,7 +56,7 @@ class BookmarkBadgeDelegate(
             oldPath.takeIf { it.isNotBlank() }?.let { runCatching { File(it).delete() } }
             readSettingsRepository.update { it.copy(bookmarkBadgeImage = "") }
             readSettingsRepository.preferences.first { it.bookmarkBadgeImage.isEmpty() }
-            emitEffect(ReadBookEffect.UpdateReadViewConfig(setOf(ConfigUpdateAction.UpdateStyle)))
+            emitEffect(ReadBookEffect.UpdateReaderConfig(setOf(ConfigUpdateAction.UpdateStyle)))
         }
     }
 

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/MarkingDelegate.kt

@@ -188,10 +188,10 @@ class MarkingDelegate(
      * 位置来自当前已排版章节;若选区位置和合成正文略有偏差,则在附近窗口寻找选中文本。
      */
     private fun selectionContext(selection: Bookmark): Pair<String, String> {
-        val chapter = ReadBook.curTextChapter
+        val chapter = ReadBook.readerChapterInputWindow.current
             ?.takeIf { it.chapter.index == selection.chapterIndex }
             ?: return "" to ""
-        val content = chapter.getContent()
+        val content = chapter.source.semanticContent
         val expectedStart = selection.chapterPos.coerceIn(0, content.length)
         val windowStart = max(0, expectedStart - CONTEXT_SEARCH_WINDOW)
         val nearStart = content.indexOf(selection.bookText, windowStart)

+ 7 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt

@@ -300,7 +300,9 @@ class ReadAiDelegate(
             return
         }
         val chapterTitle = host.chapterName
-        val visibleContent = ReadBook.curTextChapter?.getContent().orEmpty()
+        val visibleContent = ReadBook.readerChapterInputWindow.current
+            ?.takeIf { it.chapter.index == chapterIndex }
+            ?.source?.semanticContent.orEmpty()
         val (contextBefore, contextAfter) = buildSelectionContext(
             content = visibleContent,
             selectedText = text,
@@ -507,7 +509,7 @@ class ReadAiDelegate(
 
     fun openAiCurrentChapterRewrite() {
         val book = ReadBook.book ?: return
-        val chapter = ReadBook.curTextChapter?.chapter ?: return
+        val chapter = ReadBook.readerChapterInputWindow.current?.chapter ?: return
         scope.launch {
             val text = withContext(IO) {
                 getEffectiveChapterContent(book, chapter).trim()
@@ -536,7 +538,9 @@ class ReadAiDelegate(
         val book = ReadBook.book ?: return
         val chapterTitle = host.findChapter(book.bookUrl, chapterIndex)?.title
             ?: host.chapterName
-        val visibleContent = ReadBook.curTextChapter?.getContent().orEmpty()
+        val visibleContent = ReadBook.readerChapterInputWindow.current
+            ?.takeIf { it.chapter.index == chapterIndex }
+            ?.source?.semanticContent.orEmpty()
         val (contextBefore, contextAfter) = buildSelectionContext(
             content = visibleContent,
             selectedText = text,

+ 2 - 6
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -441,13 +441,10 @@ class ReadAloudDelegate(
         scope.launch {
             val shouldRestart = BaseReadAloudService.isRun
             val resumePlaying = shouldRestart && !BaseReadAloudService.pause
-            val chapter = ReadBook.curTextChapter
             val chapterPosition = readAloudSessionStore.state.value.playback.chapterPosition
             readAloudSettingsRepository.update { it.copy(useMultiSpeaker = value) }
             host.updateState { it.copy(useMultiSpeaker = value) }
-            if (shouldRestart && chapter != null) {
-                val pageIndex = chapter.getPageIndexByCharIndex(chapterPosition)
-                val startPos = chapterPosition - chapter.getReadLength(pageIndex)
+            if (shouldRestart && ReadBook.readerChapterInputWindow.current != null) {
                 ReadAloud.stop(context)
                 val stopped = withTimeoutOrNull(2_000) {
                     readAloudSessionStore.state.first {
@@ -459,8 +456,7 @@ class ReadAloudDelegate(
                 ReadAloud.play(
                     context = context,
                     play = resumePlaying,
-                    pageIndex = pageIndex,
-                    startPos = startPos.coerceAtLeast(0),
+                    chapterPosition = chapterPosition.coerceAtLeast(0),
                 )
             }
         }

+ 9 - 18
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -20,8 +20,6 @@ import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.usecase.BookmarkTargetVerdict
 import io.legado.app.model.translation.TranslationChapterStatus
-import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
 import kotlinx.collections.immutable.ImmutableList
@@ -252,8 +250,6 @@ data class ReadBookUiState(
     // Translation
     val translationMode: Boolean = false,
     val translationStatus: TranslationChapterStatus = TranslationChapterStatus.Idle,
-    // Chapter info
-    val curTextChapter: TextChapter? = null,
     // Time / battery (from EventBus)
     val time: String = "",
     val battery: Int = 0,
@@ -689,7 +685,10 @@ sealed interface ReadBookIntent {
 
     // Default font picker (needs Activity for AlertDialog)
     // Text action menu (moved from Activity)
-    data class TextActionAloud(val text: String, val selectStartPos: TextPos?) : ReadBookIntent
+    data class TextActionAloud(
+        val text: String,
+        val chapterPosition: Int? = null,
+    ) : ReadBookIntent
     data class TextActionBookmark(val bookmark: Bookmark) : ReadBookIntent
     data class OpenMarking(val selection: Bookmark) : ReadBookIntent
 
@@ -837,8 +836,8 @@ sealed interface ReadBookEffect {
     // Navigation / lifecycle
     data object Finish : ReadBookEffect
 
-    // ReadView operations (require Activity/View reference)
-    data class UpdateReadViewConfig(val actions: Set<ConfigUpdateAction>) : ReadBookEffect
+    // Reader renderer operations handled by the route/controller boundary.
+    data class UpdateReaderConfig(val actions: Set<ConfigUpdateAction>) : ReadBookEffect
     data class UpContent(
         val relativePosition: Int,
         val resetPageOffset: Boolean,
@@ -875,16 +874,7 @@ sealed interface ReadBookEffect {
         val bookUrl: String,
         val autoFocus: Boolean = true,
     ) : ReadBookEffect
-    data class NavigateToSearchResult(
-        val result: SearchResult,
-        val chapterIndex: Int,
-        val pageIndex: Int,
-        val lineIndex: Int,
-        val startCharIndex: Int,
-        val endRelativePage: Int,
-        val endLineIndex: Int,
-        val endCharIndex: Int,
-    ) : ReadBookEffect
+    data class NavigateToSearchResult(val result: SearchResult) : ReadBookEffect
     data object ExitSearch : ReadBookEffect
 
     // Source actions
@@ -918,7 +908,7 @@ sealed interface ReadBookEffect {
     data class SyncBookProgress(val book: Book) : ReadBookEffect
 
     // Text action menu (needs Activity for View operations)
-    data class TextActionAloudSelect(val selectStartPos: TextPos) : ReadBookEffect
+    data class TextActionAloudPosition(val chapterPosition: Int) : ReadBookEffect
     data class TextActionSpeak(val text: String) : ReadBookEffect
     data class TextActionReplace(val text: String, val bookName: String?, val bookSourceUrl: String?) : ReadBookEffect
 
@@ -955,6 +945,7 @@ sealed interface ReadBookEffect {
 
     // Page anim changed — Activity calls readView.upPageAnim() + ReadBook.loadContent(false)
     data object PageAnimChanged : ReadBookEffect
+    data class InvalidateReaderImage(val source: String) : ReadBookEffect
 
     // Download chapters — Activity calls CacheBook.start()
     data class DownloadChapters(val start: Int, val end: Int) : ReadBookEffect

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 765 - 261
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt


+ 14 - 8
app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt

@@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onEmpty
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.withContext
 import java.io.FileNotFoundException
 import kotlin.coroutines.coroutineContext
 
@@ -50,8 +51,8 @@ import kotlin.coroutines.coroutineContext
  * 从导航请求解析出书,装载目录与正文,处理本地文件缺失、换源(手动与自动)、
  * 以及与云端阅读进度的双向同步。
  *
- * **无自持状态**:唯一的状态是 `isInitFinish`(ReadView 首帧靠它放行前后章排版,
- * 见 `prepareCachedChapterFallback` 的说明),必须留在 [ReadBookUiState]。
+ * **无自持状态**:唯一的状态是 `isInitFinish`(Compose 阅读路由用它表达开书初始化完成),
+ * 必须留在 [ReadBookUiState]。
  * 故与 [ReadConfigUpdateDelegate] 同形,读写经 [Host]。
  */
 class ReadBookLoadDelegate(
@@ -93,22 +94,27 @@ class ReadBookLoadDelegate(
 
     private var changeSourceCoroutine: Coroutine<*>? = null
 
-    suspend fun initReadBookConfig(request: ReadBookInitRequest) {
+    suspend fun initReadBookConfig(request: ReadBookInitRequest): Book? = withContext(Dispatchers.IO) {
         val bookUrl = request.bookUrl
         val book = when {
             bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
             else -> bookRepository.getBook(bookUrl)
-        } ?: return
+        } ?: return@withContext null
         ReadBook.upReadBookConfig(book)
+        book
     }
 
-    fun initData(request: ReadBookInitRequest, success: (() -> Unit)? = null) {
+    fun initData(
+        request: ReadBookInitRequest,
+        initialBook: Book? = null,
+        success: (() -> Unit)? = null,
+    ) {
         Coroutine.async(scope, Dispatchers.IO) {
             host.syncReadPreferencesSnapshot()
             ReadBook.inBookshelf = request.inBookshelf
             ReadBook.chapterChanged = request.chapterChanged
             val bookUrl = request.bookUrl
-            val book = when {
+            val book = initialBook ?: when {
                 bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
                 else -> bookRepository.getBook(bookUrl)
             } ?: ReadBook.book
@@ -164,7 +170,7 @@ class ReadBookLoadDelegate(
                         SourceCallBack.START_READ,
                         it,
                         book,
-                        ReadBook.curTextChapter?.chapter
+                        ReadBook.readerChapterInputWindow.current?.chapter
                     )
                 }
             }
@@ -175,7 +181,7 @@ class ReadBookLoadDelegate(
                         SourceCallBack.START_READ,
                         it,
                         book,
-                        ReadBook.curTextChapter?.chapter
+                        ReadBook.readerChapterInputWindow.current?.chapter
                     )
                 }
             }

+ 285 - 178
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -9,32 +9,45 @@ import android.hardware.SensorManager
 import android.os.SystemClock
 import android.view.KeyEvent
 import android.view.View
-import android.view.ViewTreeObserver
-import android.widget.FrameLayout
-import android.widget.ImageView
 import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.BackHandler
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.EnterExitState
+import androidx.compose.animation.ExperimentalSharedTransitionApi
+import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.scaleIn
 import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.clickable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextAlign
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -48,15 +61,16 @@ import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
+import io.legado.app.feature.reader.ReaderCanvasSurface
+import io.legado.app.feature.reader.ReaderBackgroundSurface
+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.help.IntentHelp
 import io.legado.app.model.ReadBook
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.ui.book.info.BookInfoActivity
-import io.legado.app.ui.book.read.page.ContentTextView
-import io.legado.app.ui.book.read.page.ReadView
-import io.legado.app.ui.book.read.page.ReaderEventListener
-import io.legado.app.ui.book.read.page.ReaderPageSource
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.ReaderBookSourceActions
@@ -68,6 +82,8 @@ import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.ui.theme.LocalAppUiConfiguration
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.toastOnUi
@@ -82,21 +98,7 @@ import kotlin.time.Duration.Companion.minutes
 import kotlin.time.Duration.Companion.seconds
 
 
-data class ReadBookViewRefs(
-    val root: FrameLayout,
-    val readView: ReadView,
-    val textMenuPosition: View,
-    val cursorLeft: ImageView,
-    val cursorRight: ImageView,
-    val navigationBar: View,
-)
-
-interface ReadBookRouteHost :
-    View.OnTouchListener,
-    ReadView.CallBack,
-    ReaderEventListener,
-    ReaderPageSource,
-    ContentTextView.CallBack {
+interface ReadBookRouteHost {
 
     val isInMultiWindowModeCompat: Boolean
 
@@ -104,6 +106,8 @@ interface ReadBookRouteHost :
 
     fun previewBrightness(value: Int)
 
+    fun upSystemUiVisibility()
+
     fun upSystemUiVisibility(
         isInMultiWindow: Boolean,
         toolBarHide: Boolean,
@@ -126,11 +130,16 @@ interface ReadBookInputHandler {
  * Outer wrapper for ReadBookScreen — handles system UI state sync
  * and ActivityResult launcher registration.
  */
+@OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 fun ReadBookRouteScreen(
     viewModel: ReadBookViewModel,
+    readerSessionViewModel: ReaderSessionViewModel,
     host: ReadBookRouteHost,
     controller: ReadBookController,
+    sharedTransitionScope: SharedTransitionScope? = null,
+    animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    sharedCoverKey: String? = null,
     onEffectsReady: () -> Unit = {},
     onOpenSearch: (word: String?, bookUrl: String, autoFocus: Boolean) -> Unit = { _, _, _ -> },
     onOpenVoiceCasting: (bookUrl: String) -> Unit = {},
@@ -138,16 +147,26 @@ fun ReadBookRouteScreen(
     onOpenTtsCache: () -> Unit = {},
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
-    val aiState by viewModel.aiState.collectAsStateWithLifecycle()
-    val highlightRuleState by viewModel.highlightRuleState.collectAsStateWithLifecycle()
-    val markingState by viewModel.markingState.collectAsStateWithLifecycle()
-    val contentEditState by viewModel.contentEditState.collectAsStateWithLifecycle()
-    val contentProcessState by viewModel.contentProcessState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
-    val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
+    val readerRenderState by readerSessionViewModel.uiState.collectAsStateWithLifecycle()
+    val readerPageWindow = readerRenderState.pageWindow
+    val readerPaginationError = readerRenderState.paginationError
+    val readerBackground = readerRenderState.background
+    val localDensity = LocalDensity.current
+    val density = localDensity.density
+    val layoutDirection = LocalLayoutDirection.current
+    val safeDrawingInsets = WindowInsets.safeDrawing
+    val readerContentPadding = ReaderPadding(
+        left = safeDrawingInsets.getLeft(localDensity, layoutDirection),
+        top = safeDrawingInsets.getTop(localDensity),
+        right = safeDrawingInsets.getRight(localDensity, layoutDirection),
+        bottom = safeDrawingInsets.getBottom(localDensity),
+    )
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
-    val isDarkTheme = LocalAppUiConfiguration.current.isDarkTheme
+    val appUiConfiguration = LocalAppUiConfiguration.current
+    val isDarkTheme = appUiConfiguration.isDarkTheme
+    val isEInkMode = appUiConfiguration.theme.appTheme == "4"
     val eyeProtectionActive = rememberEyeProtectionActive(
         enabled = state.eyeProtection.enabled,
         autoNight = state.eyeProtection.autoNight,
@@ -165,17 +184,23 @@ fun ReadBookRouteScreen(
                     !state.menuConfig.readMenuFloatingBottomBar &&
                             state.menuConfig.readMenuBottomBarBlurMode == ReadMenuBlurMode.LiquidGlass
                     )
-
+    BackHandler {
+        when {
+            state.activeSheet != null -> viewModel.onIntent(ReadBookIntent.DismissSheet)
+            state.isShowingSearchResult -> viewModel.onIntent(ReadBookIntent.ExitSearch)
+            state.isAutoPage -> viewModel.onIntent(ReadBookIntent.StopAutoPage)
+            state.menuState.canNavigateBack -> viewModel.onIntent(ReadBookIntent.ReadMenuBack)
+            else -> viewModel.onIntent(ReadBookIntent.CloseReadBook())
+        }
+    }
     DisposableEffect(controller) {
+        controller.onComposeRendererAttached()
         onDispose {
+            controller.onComposeRendererDetached()
             controller.clearAppThemeOverride()
         }
     }
 
-    LaunchedEffect(state.menuVisible) {
-        controller.onMenuVisibilityChanged(state.menuVisible)
-    }
-
     LaunchedEffect(viewModel, controller, lifecycleOwner) {
         lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
             viewModel.readAloudProgress.collect { chapterStart ->
@@ -517,22 +542,126 @@ fun ReadBookRouteScreen(
     // ── View layer + Compose UI ───────────────────────────────────────
 
     var showSelectMenuConfigSheet by rememberSaveable { mutableStateOf(false) }
+    var featureOverlaysInitialized by remember { mutableStateOf(false) }
+    val featureOverlayRequested = state.activeSheet != null ||
+        state.activeDialog != null ||
+        state.pendingBookmarkTarget != null
+    LaunchedEffect(featureOverlayRequested) {
+        if (featureOverlayRequested) featureOverlaysInitialized = true
+    }
 
-    val firstFrameTracker = remember(controller) {
+    val firstFrameStartedAtNanos = remember(controller) {
         val requestedStart = controller.activity.intent.getLongExtra(
             EXTRA_FIRST_FRAME_STARTED_AT_NANOS,
             0L,
         )
-        ReaderFirstFrameTracker(
-            startedAtNanos = requestedStart.takeIf { it > 0L }
-                ?: SystemClock.elapsedRealtimeNanos(),
-        )
+        requestedStart.takeIf { it > 0L } ?: SystemClock.elapsedRealtimeNanos()
+    }
+    val loadingFrameTracker = remember(controller) {
+        ReaderFirstFrameTracker(firstFrameStartedAtNanos)
+    }
+    val contentFrameTracker = remember(controller) {
+        ReaderFirstFrameTracker(firstFrameStartedAtNanos)
+    }
+    LaunchedEffect(Unit) {
+        withFrameNanos { }
+        loadingFrameTracker.report(ReaderStartupFramePhase.LOADING)
+    }
+    LaunchedEffect(isDarkTheme) {
+        controller.onAppThemeChanged(isDarkTheme)
+    }
+    LaunchedEffect(readerPageWindow.current?.id, readerPageWindow.current?.layoutRevision) {
+        if (readerPageWindow.current != null) {
+            withFrameNanos { }
+            contentFrameTracker.report(ReaderStartupFramePhase.CONTENT)
+        }
     }
 
-    Box(Modifier.fillMaxSize()) {
-        key(controller) {
-            ReadBookViewLayer(
+    val readerSurfaceColor = Color(
+        readerBackground.meanColorArgb.takeIf { it != 0 } ?: runCatching {
+            android.graphics.Color.parseColor(
+                if (isDarkTheme) state.styleConfig.bgStrNight else state.styleConfig.bgStr
+            )
+        }.getOrDefault(
+            if (isDarkTheme) android.graphics.Color.BLACK else android.graphics.Color.WHITE
+        )
+    )
+    val readerEntranceSettled = animatedVisibilityScope?.transition?.let { transition ->
+        !transition.isRunning &&
+            transition.currentState == EnterExitState.Visible &&
+            transition.targetState == EnterExitState.Visible
+    } ?: true
+    LaunchedEffect(readerEntranceSettled) {
+        controller.onReaderEntranceStateChanged(readerEntranceSettled)
+        if (readerEntranceSettled) viewModel.onReaderEntranceSettled()
+    }
+    val hasReadablePage = readerPageWindow.current != null && state.msg == null
+    var readerContentRevealAllowed by remember(sharedCoverKey) {
+        mutableStateOf(sharedCoverKey == null || animatedVisibilityScope == null)
+    }
+    LaunchedEffect(sharedCoverKey, animatedVisibilityScope) {
+        if (!readerContentRevealAllowed) {
+            delay(300)
+            readerContentRevealAllowed = true
+        }
+    }
+    Box(
+        Modifier
+            .fillMaxSize()
+            .then(
+                with(sharedTransitionScope) {
+                    if (this != null &&
+                        animatedVisibilityScope != null &&
+                        sharedCoverKey != null
+                    ) {
+                        Modifier.sharedBounds(
+                            sharedContentState = rememberSharedContentState(sharedCoverKey),
+                            animatedVisibilityScope = animatedVisibilityScope,
+                            enter = fadeIn(animationSpec = tween(600)),
+                            exit = fadeOut(animationSpec = tween(600)),
+                        )
+                    } else {
+                        Modifier
+                    }
+                }
+            )
+            .background(readerSurfaceColor)
+    ) {
+        Box(
+            Modifier
+                .fillMaxSize()
+                .onSizeChanged { size ->
+                controller.onComposeReaderViewportChanged(
+                    widthPx = size.width,
+                    heightPx = size.height,
+                    density = density,
+                    contentPadding = readerContentPadding,
+                )
+            }
+        ) {
+            ReaderBackgroundSurface(
+                backgroundImage = readerBackground.drawable,
+                backgroundImageAlpha = readerBackgroundAlpha(state.styleConfig.bgAlpha),
+                modifier = Modifier.fillMaxSize(),
+                animateAppearance = true,
+            )
+            AnimatedVisibility(
+                visible = readerContentRevealAllowed && hasReadablePage,
+                enter = fadeIn(animationSpec = tween(450)),
+                exit = fadeOut(animationSpec = tween(450)),
+            ) {
+                ReaderCanvasSurface(
+                pages = readerPageWindow,
+                transitionMode = ReaderTransitionMode.fromPageAnim(controller.pageAnim),
+                backgroundColor = readerSurfaceColor,
+                backgroundImage = readerBackground.drawable,
+                backgroundRevision = readerBackground.revision,
+                backgroundImageAlpha = readerBackgroundAlpha(state.styleConfig.bgAlpha),
+                selectionColor = LegadoTheme.colorScheme.primary.copy(alpha = 0.28f),
+                textAccentColor = Color(state.sheetConfig.textAccentColor),
+                autoPageIndicatorColor = LegadoTheme.colorScheme.primary,
                 modifier = Modifier
+                    .fillMaxSize()
                     .then(
                         if (useMenuHazeSource) {
                             Modifier.hazeSource(menuHazeState)
@@ -541,16 +670,79 @@ fun ReadBookRouteScreen(
                         }
                     )
                     .layerBackdrop(menuBackdrop),
-                onRefsReady = { controller.onRefsReady(it) },
-                onCursorTouch = controller,
-                readViewCallBack = controller,
-                readerEventListener = controller,
-                readerPageSource = controller,
-                contentTextViewCallBack = controller,
-                isDarkTheme = isDarkTheme,
-                onThemeChanged = controller::onAppThemeChanged,
-                onFirstContentDrawn = firstFrameTracker::report,
-            )
+                onPreviousPage = { controller.completeComposePageTurn(PageDirection.PREV) },
+                onNextPage = { controller.completeComposePageTurn(PageDirection.NEXT) },
+                onToggleMenu = controller::showComposeActionMenu,
+                onToggleBookmark = { viewModel.onIntent(ReadBookIntent.ToggleBookmark) },
+                swipeToBookmarkEnabled = readPreferences.swipeToAddBookmark,
+                hasBookmarkOnCurrentPage = controller::hasBookmarkOnComposePage,
+                cachedImage = controller::cachedReaderImage,
+                loadImage = controller::loadReaderImage,
+                autoPageActive = state.isAutoPage,
+                autoPagePaused = state.menuVisible,
+                autoReadSpeedSeconds = readPreferences.autoReadSpeed,
+                isEInkMode = isEInkMode,
+                onAutoPageStop = controller::stopAutoPage,
+                onShowSelectionMenu = controller::showComposeTextActionMenu,
+                onDismissSelectionMenu = controller::dismissTextActionMenu,
+                onElementClick = controller::onComposeReaderElementClick,
+                onElementLongPress = controller::onComposeReaderElementLongPress,
+                selectionEnabled = readPreferences.selectText,
+                selectionHapticsEnabled = readPreferences.selectVibrator,
+                tapActionGrid = ReaderTapActionGrid.fromLegacyValues(
+                    readPreferences.clickActionTL,
+                    readPreferences.clickActionTC,
+                    readPreferences.clickActionTR,
+                    readPreferences.clickActionML,
+                    readPreferences.clickActionMC,
+                    readPreferences.clickActionMR,
+                    readPreferences.clickActionBL,
+                    readPreferences.clickActionBC,
+                    readPreferences.clickActionBR,
+                ),
+                onTapAction = controller::onComposeTapAction,
+                onReaderInteraction = controller::screenOffTimerStart,
+                configuredTouchSlopPx = readPreferences.pageTouchSlop,
+                noAnimationScrollPage = readPreferences.noAnimScrollPage,
+                externalPageTurns = controller.composePageTurns,
+                externalSelectionCancels = controller.composeSelectionCancels,
+                )
+            }
+            AnimatedVisibility(
+                visible = readerEntranceSettled && !hasReadablePage,
+                enter = fadeIn(animationSpec = tween(300)),
+                exit = fadeOut(animationSpec = tween(300)),
+            ) {
+                val message = state.msg ?: if (readerPaginationError != null) {
+                    stringResource(R.string.load_error_retry)
+                } else {
+                    stringResource(R.string.data_loading)
+                }
+                val retryable = state.msg == null && readerPaginationError != null
+                val retryLabel = stringResource(R.string.dynamic_click_retry)
+                Box(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .then(
+                            if (retryable) {
+                                Modifier.clickable(
+                                    role = Role.Button,
+                                    onClickLabel = retryLabel,
+                                    onClick = controller::retryComposeReaderPagination,
+                                )
+                            } else {
+                                Modifier
+                            }
+                        ),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    AppText(
+                        text = if (retryable) "$message\n$retryLabel" else message,
+                        color = LegadoTheme.colorScheme.onSurface,
+                        textAlign = TextAlign.Center,
+                    )
+                }
+            }
         }
         ReadBookColorTheme(
             styleConfig = state.styleConfig,
@@ -561,7 +753,13 @@ fun ReadBookRouteScreen(
                 state = state,
                 preferences = readPreferences,
                 eyeProtectionActive = eyeProtectionActive,
-                onIntent = viewModel::onIntent,
+                onIntent = { intent ->
+                    if (intent !is ReadBookIntent.SkipToPage ||
+                        !controller.seekComposeChapterPage(intent.pageIndex)
+                    ) {
+                        viewModel.onIntent(intent)
+                    }
+                },
                 onBrightnessPreview = host::previewBrightness,
                 backdrop = menuBackdrop,
                 hazeState = if (useMenuHazeSource) menuHazeState else null,
@@ -600,23 +798,21 @@ fun ReadBookRouteScreen(
                     onOpenPlayer = { viewModel.onIntent(ReadBookIntent.OpenReadAloudPlayer) },
                 )
             }
-            ReadBookScreen(
-                state = state,
-                aiState = aiState,
-                highlightRuleState = highlightRuleState,
-                markingState = markingState,
-                contentEditState = contentEditState,
-                contentProcessState = contentProcessState,
-                preferences = readPreferences,
-                onIntent = viewModel::onIntent,
-                onBack = { controller.closeReadBook() },
-                onOpenTextSelectMenuConfig = {
-                    viewModel.onIntent(ReadBookIntent.DismissSheet)
-                    showSelectMenuConfigSheet = true
-                },
-                onPickBookmarkBadgeImage = { bookmarkBadgeImagePicker.launch("image/*") },
-                onResetBookmarkBadge = { viewModel.onIntent(ReadBookIntent.ClearBookmarkBadgeImage) },
-            )
+            if (featureOverlaysInitialized) {
+                ReadBookOverlayRoute(
+                    viewModel = viewModel,
+                    state = state,
+                    preferences = readPreferences,
+                    onOpenTextSelectMenuConfig = {
+                        viewModel.onIntent(ReadBookIntent.DismissSheet)
+                        showSelectMenuConfigSheet = true
+                    },
+                    onPickBookmarkBadgeImage = { bookmarkBadgeImagePicker.launch("image/*") },
+                    onResetBookmarkBadge = {
+                        viewModel.onIntent(ReadBookIntent.ClearBookmarkBadgeImage)
+                    },
+                )
+            }
             val bookNavigationSheet = state.activeSheet as? ReadBookSheet.BookNavigation
             ReaderBookSheetRoute(
                 show = bookNavigationSheet != null,
@@ -663,16 +859,10 @@ fun ReadBookRouteScreen(
                     onDisable = { viewModel.onIntent(ReadBookIntent.DisableSource) },
                 ),
             )
-            TextActionSelectionMenu(
-                menuState = textMenuState,
+            ReaderTextSelectionOverlay(
+                controller = controller,
                 expandTextMenu = readPreferences.expandTextMenu,
-                onDismiss = { controller.dismissTextActionMenu() },
-                onItemClick = { item -> controller.onTextMenuItemClick(item) },
-                onOpenManage = {
-                    controller.dismissTextActionMenu()
-                    controller.refs?.readView?.cancelSelect()
-                    showSelectMenuConfigSheet = true
-                }
+                onOpenManage = { showSelectMenuConfigSheet = true },
             )
             var configItems by remember { mutableStateOf<List<ActionMenuItem>>(emptyList()) }
             LaunchedEffect(showSelectMenuConfigSheet) {
@@ -702,108 +892,25 @@ fun ReadBookRouteScreen(
 }
 
 @Composable
-private fun ReadBookViewLayer(
-    modifier: Modifier = Modifier,
-    onRefsReady: (ReadBookViewRefs) -> Unit,
-    onCursorTouch: View.OnTouchListener,
-    readViewCallBack: ReadView.CallBack,
-    readerEventListener: ReaderEventListener,
-    readerPageSource: ReaderPageSource,
-    contentTextViewCallBack: ContentTextView.CallBack,
-    isDarkTheme: Boolean,
-    onThemeChanged: (Boolean) -> Unit,
-    onFirstContentDrawn: () -> Unit,
+private fun ReaderTextSelectionOverlay(
+    controller: ReadBookController,
+    expandTextMenu: Boolean,
+    onOpenManage: () -> Unit,
 ) {
-    AndroidView(
-        modifier = modifier.fillMaxSize(),
-        factory = { context ->
-            onThemeChanged(isDarkTheme)
-            FrameLayout(context).apply {
-                val readView = ReadView(
-                    context = context,
-                    callBack = readViewCallBack,
-                    contentCallBack = contentTextViewCallBack,
-                    eventListener = readerEventListener,
-                    pageSource = readerPageSource,
-                ).apply {
-                    layoutParams = FrameLayout.LayoutParams(
-                        FrameLayout.LayoutParams.MATCH_PARENT,
-                        FrameLayout.LayoutParams.MATCH_PARENT,
-                    )
-                }
-                val textMenuPosition = View(context).apply {
-                    id = R.id.text_menu_position
-                    visibility = View.INVISIBLE
-                    layoutParams = FrameLayout.LayoutParams(0, 0)
-                }
-                val cursorLeft = ImageView(context).apply {
-                    id = R.id.cursor_left
-                    contentDescription = context.getString(R.string.select_start)
-                    setImageResource(R.drawable.ic_cursor_left)
-                    visibility = View.INVISIBLE
-                    setOnTouchListener(onCursorTouch)
-                    layoutParams = FrameLayout.LayoutParams(
-                        FrameLayout.LayoutParams.WRAP_CONTENT,
-                        FrameLayout.LayoutParams.WRAP_CONTENT,
-                    )
-                }
-                val cursorRight = ImageView(context).apply {
-                    id = R.id.cursor_right
-                    contentDescription = context.getString(R.string.select_end)
-                    setImageResource(R.drawable.ic_cursor_right)
-                    visibility = View.INVISIBLE
-                    setOnTouchListener(onCursorTouch)
-                    layoutParams = FrameLayout.LayoutParams(
-                        FrameLayout.LayoutParams.WRAP_CONTENT,
-                        FrameLayout.LayoutParams.WRAP_CONTENT,
-                    )
-                }
-                val navigationBar = View(context).apply {
-                    id = R.id.navigation_bar
-                    layoutParams = FrameLayout.LayoutParams(
-                        FrameLayout.LayoutParams.MATCH_PARENT,
-                        0,
-                        android.view.Gravity.BOTTOM,
-                    )
-                }
-
-                addView(readView)
-                addView(textMenuPosition)
-                addView(cursorLeft)
-                addView(cursorRight)
-                addView(navigationBar)
-
-                onRefsReady(
-                    ReadBookViewRefs(
-                        root = this,
-                        readView = readView,
-                        textMenuPosition = textMenuPosition,
-                        cursorLeft = cursorLeft,
-                        cursorRight = cursorRight,
-                        navigationBar = navigationBar,
-                    )
-                )
-                val root = this
-                val firstDrawListener = object : ViewTreeObserver.OnDrawListener {
-                    override fun onDraw() {
-                        if (readView.curPage.textPage.lines.isEmpty()) return
-                        onFirstContentDrawn()
-                        root.post {
-                            if (root.viewTreeObserver.isAlive) {
-                                root.viewTreeObserver.removeOnDrawListener(this)
-                            }
-                        }
-                    }
-                }
-                viewTreeObserver.addOnDrawListener(firstDrawListener)
-            }
-        },
-        update = {
-            onThemeChanged(isDarkTheme)
+    val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
+    TextActionSelectionMenu(
+        menuState = textMenuState,
+        expandTextMenu = expandTextMenu,
+        onDismiss = controller::dismissTextActionMenu,
+        onItemClick = controller::onTextMenuItemClick,
+        onOpenManage = {
+            controller.dismissTextActionMenu()
+            onOpenManage()
         },
     )
 }
 
+
 @Composable
 private fun AutoSuggestDayNightObserver(
     viewModel: ReadBookViewModel,

+ 64 - 14
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -1,6 +1,5 @@
 package io.legado.app.ui.book.read
 
-import androidx.activity.compose.BackHandler
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.material3.Checkbox
@@ -68,9 +67,71 @@ import org.koin.compose.koinInject
 import io.legado.app.model.BookCover as BookCoverModel
 
 /**
- * Stateless ReadBook screen — renders BackHandler + dialogs + sheets.
- * ReadView is hosted in the XML layout, not here.
+ * Stateless reader overlays: back handling, dialogs and sheets.
+ * [ReadBookRouteScreen] owns the Compose Canvas reading surface.
  */
+@Composable
+fun ReadBookOverlayRoute(
+    viewModel: ReadBookViewModel,
+    state: ReadBookUiState,
+    preferences: ReadPreferences,
+    onOpenTextSelectMenuConfig: () -> Unit,
+    onPickBookmarkBadgeImage: () -> Unit,
+    onResetBookmarkBadge: () -> Unit,
+) {
+    val aiActive = rememberFeatureActivated(
+        state.activeSheet is ReadBookSheet.ChapterSummary ||
+            state.activeSheet is ReadBookSheet.AiTextClean ||
+            state.activeSheet is ReadBookSheet.AiTextRewrite ||
+            state.activeSheet is ReadBookSheet.AiRewritePresetConfig
+    )
+    val highlightActive = rememberFeatureActivated(
+        state.activeSheet is ReadBookSheet.HighlightRuleConfig
+    )
+    val markingActive = rememberFeatureActivated(state.activeSheet is ReadBookSheet.Marking)
+    val contentEditActive = rememberFeatureActivated(state.activeSheet is ReadBookSheet.ContentEdit)
+    val contentProcessActive = rememberFeatureActivated(
+        state.activeSheet is ReadBookSheet.TextProcessing
+    )
+    val aiState = if (aiActive) {
+        viewModel.aiState.collectAsStateWithLifecycle().value
+    } else ReadAiUiState()
+    val highlightRuleState = if (highlightActive) {
+        viewModel.highlightRuleState.collectAsStateWithLifecycle().value
+    } else HighlightRuleConfigUiState()
+    val markingState = if (markingActive) {
+        viewModel.markingState.collectAsStateWithLifecycle().value
+    } else MarkingUiState()
+    val contentEditState = if (contentEditActive) {
+        viewModel.contentEditState.collectAsStateWithLifecycle().value
+    } else ContentEditUiState()
+    val contentProcessState = if (contentProcessActive) {
+        viewModel.contentProcessState.collectAsStateWithLifecycle().value
+    } else ContentProcessConfigUiState()
+    ReadBookScreen(
+        state = state,
+        aiState = aiState,
+        highlightRuleState = highlightRuleState,
+        markingState = markingState,
+        contentEditState = contentEditState,
+        contentProcessState = contentProcessState,
+        preferences = preferences,
+        onIntent = viewModel::onIntent,
+        onOpenTextSelectMenuConfig = onOpenTextSelectMenuConfig,
+        onPickBookmarkBadgeImage = onPickBookmarkBadgeImage,
+        onResetBookmarkBadge = onResetBookmarkBadge,
+    )
+}
+
+@Composable
+private fun rememberFeatureActivated(active: Boolean): Boolean {
+    var activated by remember { mutableStateOf(active) }
+    LaunchedEffect(active) {
+        if (active) activated = true
+    }
+    return active || activated
+}
+
 @Composable
 fun ReadBookScreen(
     state: ReadBookUiState,
@@ -81,21 +142,10 @@ fun ReadBookScreen(
     contentProcessState: ContentProcessConfigUiState,
     preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
-    onBack: () -> Unit,
     onOpenTextSelectMenuConfig: () -> Unit,
     onPickBookmarkBadgeImage: () -> Unit,
     onResetBookmarkBadge: () -> Unit,
 ) {
-    BackHandler {
-        when {
-            state.activeSheet != null -> onIntent(ReadBookIntent.DismissSheet)
-            state.isShowingSearchResult -> onIntent(ReadBookIntent.ExitSearch)
-            state.isAutoPage -> onIntent(ReadBookIntent.StopAutoPage)
-            state.menuState.canNavigateBack -> onIntent(ReadBookIntent.ReadMenuBack)
-            else -> onIntent(ReadBookIntent.CloseReadBook())
-        }
-    }
-
     // Dialogs driven by activeDialog state
     val restoreDialog = state.activeDialog as? ReadBookDialog.ConfirmRestoreProgress
     val syncDialog = state.activeDialog as? ReadBookDialog.SureSyncProgress

+ 116 - 137
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -51,6 +51,8 @@ import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
 import io.legado.app.domain.usecase.VerifyBookmarkTargetUseCase
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.feature.reader.core.navigation.ReaderChapterPagePosition
+import io.legado.app.feature.reader.core.navigation.ReaderPageContext
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isEpub
@@ -76,7 +78,6 @@ import io.legado.app.model.translation.TranslationChapterKey
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.model.translation.TranslationManager
 import io.legado.app.service.BaseReadAloudService
-import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.utils.ImageSaveUtils
@@ -155,6 +156,8 @@ class ReadBookViewModel(
     val uiState = _uiState.asStateFlow()
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
+    private var composePagePosition: ReaderChapterPagePosition? = null
+    @Volatile private var composePageContext: ReaderPageContext? = null
     private val _readAloudProgress = MutableStateFlow(
         activeReadAloudProgress(
             isPlaying = BaseReadAloudService.isPlay(),
@@ -196,7 +199,7 @@ class ReadBookViewModel(
     }
     // --- 正文处理域 ---
 
-    private val contentProcessDelegate = ReadContentProcessDelegate(
+    private val contentProcessDelegate by lazy { ReadContentProcessDelegate(
         context = context,
         scope = viewModelScope,
         host = object : ReadContentProcessDelegate.Host {
@@ -205,12 +208,12 @@ class ReadBookViewModel(
             }
         },
         bookContentProcessGateway = bookContentProcessGateway,
-    )
+    ) }
 
-    val contentProcessState = contentProcessDelegate.uiState
+    val contentProcessState get() = contentProcessDelegate.uiState
     // --- 划线/高亮笔记域:一次「选中 → 配置样式/备注 → 保存」的临时会话 ---
 
-    private val markingDelegate = MarkingDelegate(
+    private val markingDelegate by lazy { MarkingDelegate(
         scope = viewModelScope,
         context = context,
         highlightRuleRepository = highlightRuleRepository,
@@ -228,9 +231,9 @@ class ReadBookViewModel(
                 _effects.tryEmit(ReadBookEffect.ShowToast(message))
             }
         },
-    )
+    ) }
 
-    val markingState = markingDelegate.uiState
+    val markingState get() = markingDelegate.uiState
     /**
      * 划线笔记编辑可能从目录 Sheet 进入:保存/删除/取消后应回到原 sheet(目录),
      * 而不是被丢回阅读页。从划词菜单新建时无原 sheet,回 null。
@@ -245,7 +248,7 @@ class ReadBookViewModel(
 
     // --- 跳转校验域:书签/笔记跳转前比对源与章节标题 ---
 
-    private val bookmarkNavigateDelegate = ReadBookmarkNavigateDelegate(
+    private val bookmarkNavigateDelegate by lazy { ReadBookmarkNavigateDelegate(
         scope = viewModelScope,
         bookRepository = bookRepository,
         verifyUseCase = verifyBookmarkTargetUseCase,
@@ -263,7 +266,7 @@ class ReadBookViewModel(
                 _uiState.update { it.copy(pendingBookmarkTarget = pending) }
             }
         },
-    )
+    ) }
 
     // --- AI 域(摘要 / 净化 / 重写 / 预设)---
 
@@ -298,7 +301,7 @@ class ReadBookViewModel(
             bookRepository.getChapters(bookUrl)
     }
 
-    private val aiDelegate = ReadAiDelegate(
+    private val aiDelegate by lazy { ReadAiDelegate(
         context = context,
         scope = viewModelScope,
         host = aiHost,
@@ -308,12 +311,12 @@ class ReadBookViewModel(
         saveBookContentProcessUseCase = saveBookContentProcessUseCase,
         aiArtifactGateway = aiArtifactGateway,
         aiPromptPresetGateway = aiPromptPresetGateway,
-    )
+    ) }
 
-    val aiState = aiDelegate.uiState
+    val aiState get() = aiDelegate.uiState
     // --- 高亮规则域 ---
 
-    private val highlightRuleDelegate = ReadHighlightRuleDelegate(
+    private val highlightRuleDelegate by lazy { ReadHighlightRuleDelegate(
         context = context,
         scope = viewModelScope,
         host = object : ReadHighlightRuleDelegate.Host {
@@ -323,7 +326,7 @@ class ReadBookViewModel(
 
             override fun notifyRulesChanged() {
                 _effects.tryEmit(
-                    ReadBookEffect.UpdateReadViewConfig(
+                    ReadBookEffect.UpdateReaderConfig(
                         setOf(
                             ConfigUpdateAction.UpdateChapterStyle,
                             ConfigUpdateAction.ReloadContent,
@@ -334,26 +337,26 @@ class ReadBookViewModel(
         },
         highlightRuleRepository = highlightRuleRepository,
         uploadRepository = uploadRepository,
-    )
+    ) }
 
-    val highlightRuleState = highlightRuleDelegate.uiState
+    val highlightRuleState get() = highlightRuleDelegate.uiState
 
     // --- 正文编辑域 ---
 
-    private val contentEditDelegate = ReadContentEditDelegate(
+    private val contentEditDelegate by lazy { ReadContentEditDelegate(
         scope = viewModelScope,
         host = object : ReadContentEditDelegate.Host {
+            override val currentCanvasPage: ReaderPageContext? get() = composePageContext
             override fun setActiveSheet(sheet: ReadBookSheet?) {
                 _uiState.update { it.copy(activeSheet = sheet) }
             }
 
-            override suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? =
-                bookRepository.getChapter(bookUrl, chapterIndex)
+            override suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter? = bookRepository.getChapter(bookUrl, chapterIndex)
         },
         readSettingsRepository = readSettingsRepository,
-    )
+    ) }
 
-    val contentEditState = contentEditDelegate.uiState
+    val contentEditState get() = contentEditDelegate.uiState
 
     // --- 配置更新分发(无自持状态,menuConfig 仍在 ReadBookUiState)---
 
@@ -389,27 +392,27 @@ class ReadBookViewModel(
 
     // --- 书签域(无自持状态,草稿随 ReadBookSheet.Bookmark 走)---
 
-    private val bookmarkDelegate = ReadBookmarkDelegate(
+    private val bookmarkDelegate by lazy { ReadBookmarkDelegate(
         scope = viewModelScope,
         host = object : ReadBookmarkDelegate.Host {
-            override fun setActiveSheet(sheet: ReadBookSheet?) {
-                _uiState.update { it.copy(menuState = ReadBookMenuState(), activeSheet = sheet) }
-            }
+            override val currentCanvasPage: ReaderPageContext? get() = composePageContext
 
-            override fun emitEffect(effect: ReadBookEffect) {
-                _effects.tryEmit(effect)
+            override fun setActiveSheet(sheet: ReadBookSheet?) = _uiState.update {
+                it.copy(menuState = ReadBookMenuState(), activeSheet = sheet)
             }
+
+            override fun emitEffect(effect: ReadBookEffect) { _effects.tryEmit(effect) }
         },
         bookmarkRepository = bookmarkRepository,
         bookKey = _uiState.map { it.book?.let { book -> book.name to book.author } },
-    )
+    ) }
 
-    private val readRecordAliasDelegate = ReadRecordAliasDelegate(
+    private val readRecordAliasDelegate by lazy { ReadRecordAliasDelegate(
         viewModelScope, localPreferencesRepository, readRecordRepository,
         { _uiState.value.activeDialog != null },
         { book, time -> _uiState.update { it.copy(activeDialog = ReadBookDialog.ReadRecordAliasConflict(book.name, book.author, time)) } },
         { _uiState.update { it.copy(activeDialog = null) } },
-    )
+    ) }
     // --- 开书 / 目录 / 换源 / 进度同步域(无自持状态,isInitFinish 仍在 UiState)---
 
     private val loadDelegate: ReadBookLoadDelegate = ReadBookLoadDelegate(
@@ -492,19 +495,19 @@ class ReadBookViewModel(
     )
 
     /** 自定义书签角标(拷贝落盘 + 刷新角标),独立于样式域的小委托。 */
-    private val bookmarkBadgeDelegate = BookmarkBadgeDelegate(
+    private val bookmarkBadgeDelegate by lazy { BookmarkBadgeDelegate(
         scope = viewModelScope,
         context = context,
         readSettingsRepository = readSettingsRepository,
         emitEffect = _effects::tryEmit,
-    )
+    ) }
 
     /** 日夜切换冷却期内不再弹提醒;光线传感器回调在 RouteScreen 里先问这个再发 intent。 */
     fun isDayNightSwitchCoolingDown(): Boolean = styleDelegate.isDayNightSwitchCoolingDown()
 
     // --- 朗读域(无自持状态,朗读设置字段仍在 ReadBookUiState)---
 
-    private val readAloudDelegate = ReadAloudDelegate(
+    private val readAloudDelegate by lazy { ReadAloudDelegate(
         context = context,
         scope = viewModelScope,
         host = object : ReadAloudDelegate.Host {
@@ -540,11 +543,11 @@ class ReadBookViewModel(
         httpTtsRepository = httpTtsRepository,
         aiProfileGateway = aiProfileGateway,
         syncReadAloudVoicesUseCase = syncReadAloudVoicesUseCase,
-    )
+    ) }
 
     // --- 菜单按钮配置域(无自持状态,按钮列表仍在 menuConfig)---
 
-    private val buttonConfigDelegate = ReadButtonConfigDelegate(
+    private val buttonConfigDelegate by lazy { ReadButtonConfigDelegate(
         context = context,
         scope = viewModelScope,
         readSettingsRepository = readSettingsRepository,
@@ -557,7 +560,7 @@ class ReadBookViewModel(
                 configUpdateDelegate.handle(update)
             }
         },
-    )
+    ) }
 
     // --- 净化规则域(无自持状态,规则列表仍在 uiState.allReplaceRules)---
 
@@ -590,35 +593,10 @@ class ReadBookViewModel(
     private var pendingBooksDirReloadChapterList: Boolean = false
     private var translationStatusJob: Job? = null
     private var observedTranslationKey: TranslationChapterKey? = null
+    private var deferredReaderFeaturesStarted = false
 
     val isInitFinish: Boolean get() = _uiState.value.isInitFinish
 
-    private var cachedChapterBookUrl: String? = null
-
-    /**
-     * ReadView 在 Compose 组合期就构造并画第一帧, 早于 initData 完成,
-     * 那时 isInitFinish 还是 false, 于是先闪一帧 "加载数据中".
-     * 记下本路由要打开的书, 供 [isCachedChapterUsable] 在绘制时判定.
-     */
-    fun prepareCachedChapterFallback(bookUrl: String?, chapterChanged: Boolean) {
-        cachedChapterBookUrl = bookUrl?.takeUnless { it.isEmpty() || chapterChanged }
-    }
-
-    /**
-     * ReadBook 里已经有本书可直接用的章节(重新进入, 或导航期间预加载排好的).
-     * 在绘制时判定而不是组合期一次性判定: 预加载的发布可能比组合晚几十毫秒.
-     */
-    fun isCachedChapterUsable(): Boolean {
-        val bookUrl = cachedChapterBookUrl ?: return false
-        if (ReadBook.book?.bookUrl != bookUrl) return false
-        if (ReadBook.msg != null) return false
-        val chapter = ReadBook.curTextChapter ?: return false
-        if (!chapter.isLayoutSizeMatch()) return false
-        // 长章节在导航窗口内排不完整章, 只要当前页已经排出来就够画第一帧了
-        return chapter.isCompleted ||
-                (chapter.isLayoutRunning && chapter.getPage(ReadBook.durPageIndex) != null)
-    }
-
     private fun isNightTheme(): Boolean =
         appUiConfigurationGateway.currentConfiguration.isDarkTheme
 
@@ -638,15 +616,21 @@ class ReadBookViewModel(
         // collect 的挂起点——本行返回时订阅者已注册。
         collectReaderSessionEvents()
         readerSession.attach()
-        buttonConfigDelegate.refresh()
         collectReadPreferences()
         styleDelegate.collectEyeProtectionSettings()
-        readAloudDelegate.collectPreferences()
         collectEventBus()
         collectReaderSession()
         collectReadStyle()
-        bookmarkDelegate.start()
         replaceRuleDelegate.start()
+    }
+
+    /** Starts non-renderer features only after navigation/shared-bounds animation is idle. */
+    fun onReaderEntranceSettled() {
+        if (deferredReaderFeaturesStarted) return
+        deferredReaderFeaturesStarted = true
+        buttonConfigDelegate.refresh()
+        readAloudDelegate.collectPreferences()
+        bookmarkDelegate.start()
         execute { readAloudDelegate.syncConfiguredTtsVoices() }
     }
 
@@ -1090,11 +1074,11 @@ class ReadBookViewModel(
             is ReadBookIntent.MenuSameTitleRemoved -> {
                 ReadBook.book?.let {
                     val contentProcessor = ContentProcessor.get(it)
-                    val textChapter = ReadBook.curTextChapter
-                    if (textChapter != null
-                        && !textChapter.sameTitleRemoved
+                    val chapterInput = ReadBook.readerChapterInputWindow.current
+                    if (chapterInput != null
+                        && !chapterInput.content.sameTitleRemoved
                         && !contentProcessor.removeSameTitleCache.contains(
-                            textChapter.chapter.getFileName("nr")
+                            chapterInput.chapter.getFileName("nr")
                         )
                     ) {
                         _effects.tryEmit(ReadBookEffect.ShowToast("未找到可移除的重复标题"))
@@ -1143,7 +1127,7 @@ class ReadBookViewModel(
                     if (ReadBook.bookSource == null) {
                         _effects.tryEmit(ReadBookEffect.UpContent(0, true))
                     } else {
-                        ReadBook.curTextChapter = null
+                        ReadBook.clearTextChapter()
                         _effects.tryEmit(ReadBookEffect.UpContent(0, true))
                         refreshContentDur(book)
                     }
@@ -1426,9 +1410,12 @@ class ReadBookViewModel(
             // Text action menu
             is ReadBookIntent.TextActionAloud -> {
                 when (readAloudSettingsRepository.currentSettings.contentSelectSpeakMode) {
-                    1 -> intent.selectStartPos?.let {
-                        _effects.tryEmit(ReadBookEffect.TextActionAloudSelect(it.copy()))
-                    } ?: _effects.tryEmit(ReadBookEffect.TextActionSpeak(intent.text))
+                    1 -> when {
+                        intent.chapterPosition != null -> _effects.tryEmit(
+                            ReadBookEffect.TextActionAloudPosition(intent.chapterPosition)
+                        )
+                        else -> _effects.tryEmit(ReadBookEffect.TextActionSpeak(intent.text))
+                    }
                     else -> _effects.tryEmit(ReadBookEffect.TextActionSpeak(intent.text))
                 }
             }
@@ -1758,7 +1745,7 @@ class ReadBookViewModel(
                         sheetConfig = buildSheetConfig(),
                     )
                 }
-                emitEffectWhenSubscribed(ReadBookEffect.UpdateReadViewConfig(actions))
+                emitEffectWhenSubscribed(ReadBookEffect.UpdateReaderConfig(actions))
             }
         }
         viewModelScope.launch {
@@ -1989,21 +1976,22 @@ class ReadBookViewModel(
 
     private fun syncFromReadBook(current: ReadBookUiState): ReadBookUiState {
         val book = ReadBook.book
-        val textChapter = ReadBook.curTextChapter
+        val chapterInput = ReadBook.readerChapterInputWindow.current
+        val canvasPage = composePagePosition
+            ?.takeIf { it.chapterIndex == ReadBook.durChapterIndex }
         val translationStatus = observeCurrentTranslation(book, ReadBook.durChapterIndex)
         return current.copy(
             book = book,
             bookSource = ReadBook.bookSource,
             bookName = book?.name ?: "",
-            chapterName = textChapter?.title ?: "",
-            chapterUrl = textChapter?.chapter?.url ?: "",
+            chapterName = chapterInput?.displayTitle ?: "",
+            chapterUrl = chapterInput?.chapter?.url ?: "",
             chapterSize = ReadBook.chapterSize,
             durChapterIndex = ReadBook.durChapterIndex,
             durChapterPos = ReadBook.durChapterPos,
-            durPageIndex = ReadBook.durPageIndex,
+            durPageIndex = canvasPage?.pageIndex ?: ReadBook.durPageIndex,
             isLocalBook = ReadBook.isLocalBook,
             msg = ReadBook.msg,
-            curTextChapter = textChapter,
             seekProgress = calculateSeekProgress(),
             seekMax = calculateSeekMax(),
             readingAnchorAvailable = ReadBook.hasReadingAnchor(),
@@ -2011,9 +1999,9 @@ class ReadBookViewModel(
             replaceRuleEnabled = book?.getUseReplaceRule(
                 otherSettingsGateway.currentSettings.replaceEnableDefault
             ) ?: false,
-            effectiveReplaceCount = textChapter?.effectiveReplaceRules?.size ?: 0,
-            effectiveContentProcessCount = textChapter?.effectiveContentProcesses?.size ?: 0,
-            effectiveReplaceRules = textChapter?.effectiveReplaceRules.orEmpty().toImmutableList(),
+            effectiveReplaceCount = chapterInput?.content?.effectiveReplaceRules?.size ?: 0,
+            effectiveContentProcessCount = chapterInput?.content?.effectiveContentProcesses?.size ?: 0,
+            effectiveReplaceRules = chapterInput?.content?.effectiveReplaceRules.orEmpty().toImmutableList(),
             chineseConverterActive = readSettingsRepository.currentSettings.chineseConverterType > 0,
             translationMode = book?.getTranslationMode() ?: false,
             translationStatus = translationStatus,
@@ -2025,7 +2013,7 @@ class ReadBookViewModel(
             reSegment = book?.getReSegment() ?: false,
             delRubyTag = book?.getDelTag(Book.rubyTag) ?: false,
             delHTag = book?.getDelTag(Book.hTag) ?: false,
-            sameTitleRemoved = textChapter?.sameTitleRemoved ?: false,
+            sameTitleRemoved = chapterInput?.content?.sameTitleRemoved ?: false,
             isReadingProgressSyncConfigured = isReadingProgressSyncConfigured(),
             menuConfig = ReadMenuConfig(
                 titleBarIconPosition = ReadBookConfig.titleBarIconPosition,
@@ -2113,14 +2101,21 @@ class ReadBookViewModel(
 
     private fun calculateSeekProgress(): Int {
         return when (readSettingsRepository.currentSettings.progressBarBehavior) {
-            "page" -> ReadBook.durPageIndex
+            "page" -> composePagePosition
+                ?.takeIf { it.chapterIndex == ReadBook.durChapterIndex }
+                ?.pageIndex
+                ?: ReadBook.durPageIndex
             else -> ReadBook.durChapterIndex
         }
     }
 
     private fun calculateSeekMax(): Int {
         return when (readSettingsRepository.currentSettings.progressBarBehavior) {
-            "page" -> (ReadBook.curTextChapter?.pages?.size ?: 1) - 1
+            "page" -> composePagePosition
+                ?.takeIf { it.chapterIndex == ReadBook.durChapterIndex }
+                ?.pageCount
+                ?.minus(1)
+                ?: 0
             else -> ReadBook.chapterSize - 1
         }
     }
@@ -2143,8 +2138,11 @@ class ReadBookViewModel(
     suspend fun initReadBookConfig(request: ReadBookInitRequest) =
         loadDelegate.initReadBookConfig(request)
 
-    fun initData(request: ReadBookInitRequest, success: (() -> Unit)? = null) =
-        loadDelegate.initData(request, success)
+    fun initData(
+        request: ReadBookInitRequest,
+        initialBook: Book? = null,
+        success: (() -> Unit)? = null,
+    ) = loadDelegate.initData(request, initialBook, success)
 
     fun markJustInitData() {
         justInitData = true
@@ -2316,13 +2314,6 @@ class ReadBookViewModel(
         reverseContent()
     }
 
-    /** 纯定位逻辑见 [ReadSearchResultLocator];这里保留转发入口供 ReadBookController 调用。 */
-    fun searchResultPositions(
-        textChapter: TextChapter,
-        searchResult: SearchResult,
-        query: String = _uiState.value.searchContentQuery,
-    ): Array<Int> = ReadSearchResultLocator.positions(textChapter, searchResult, query)
-
     /**
      * Compute the search result position and emit [ReadBookEffect.NavigateToSearchResult]
      * so the Controller can navigate and highlight.
@@ -2330,50 +2321,17 @@ class ReadBookViewModel(
     private fun navigateToSearchResult(result: SearchResult) {
         val query = _uiState.value.searchContentQuery
         if (query.isEmpty()) return
-        val chapterIndex = result.chapterIndex
-        val textChapter = ReadBook.curTextChapter
-        if (textChapter != null && textChapter.chapter.index == chapterIndex) {
-            val pos = searchResultPositions(textChapter, result, query)
-            val lineIndex = pos[1]
-            val charIndex = pos[2]
-            val endRelativePage = pos[3]
-            val endLineIndex = pos[4]
-            val endCharIndex = pos[5]
-            _effects.tryEmit(
-                ReadBookEffect.NavigateToSearchResult(
-                    result = result,
-                    chapterIndex = chapterIndex,
-                    pageIndex = pos[0],
-                    lineIndex = lineIndex,
-                    startCharIndex = charIndex,
-                    endRelativePage = endRelativePage,
-                    endLineIndex = endLineIndex,
-                    endCharIndex = endCharIndex,
-                )
-            )
-        } else {
-            // Chapter not loaded — emit with -1 so the Controller knows to open the chapter first
-            _effects.tryEmit(
-                ReadBookEffect.NavigateToSearchResult(
-                    result = result,
-                    chapterIndex = chapterIndex,
-                    pageIndex = -1,
-                    lineIndex = 0,
-                    startCharIndex = 0,
-                    endRelativePage = 0,
-                    endLineIndex = 0,
-                    endCharIndex = 0,
-                )
-            )
-        }
+        _effects.tryEmit(
+            ReadBookEffect.NavigateToSearchResult(result)
+        )
     }
 
     fun reverseRemoveSameTitle() {
         execute {
             val book = ReadBook.book ?: return@execute
-            val textChapter = ReadBook.curTextChapter ?: return@execute
+            val chapterInput = ReadBook.readerChapterInputWindow.current ?: return@execute
             BookHelp.setRemoveSameTitle(
-                book, textChapter.chapter, !textChapter.sameTitleRemoved
+                book, chapterInput.chapter, !chapterInput.content.sameTitleRemoved
             )
             ReadBook.loadContent(ReadBook.durChapterIndex)
         }
@@ -2387,6 +2345,7 @@ class ReadBookViewModel(
                 vFile.delete()
             }
         }.onFinally {
+            _effects.tryEmit(ReadBookEffect.InvalidateReaderImage(src))
             ReadBook.loadContent(false)
         }
     }
@@ -2463,16 +2422,36 @@ class ReadBookViewModel(
     }
 
     fun refreshSeekState() {
-        _uiState.update { it.copy(
-            seekProgress = calculateSeekProgress(),
-            seekMax = calculateSeekMax(),
-        ) }
+        _uiState.update {
+            it.copy(seekProgress = calculateSeekProgress(), seekMax = calculateSeekMax())
+        }
+    }
+
+    fun updateComposeReaderPage(position: ReaderChapterPagePosition?, pageContext: ReaderPageContext?) {
+        composePagePosition = position
+        composePageContext = pageContext
+        if (position == null || position.chapterIndex != ReadBook.durChapterIndex) return
+        _uiState.update { state ->
+            state.copy(
+                durPageIndex = position.pageIndex,
+                seekProgress = if (readSettingsRepository.currentSettings.progressBarBehavior == "page") {
+                    position.pageIndex
+                } else {
+                    state.seekProgress
+                },
+                seekMax = if (readSettingsRepository.currentSettings.progressBarBehavior == "page") {
+                    position.pageCount.coerceAtLeast(1) - 1
+                } else {
+                    state.seekMax
+                },
+            )
+        }
     }
 
     private fun openChapterUrl() {
         if (ReadBook.isLocalBook) return
         viewModelScope.launch {
-            val chapter = ReadBook.curTextChapter?.chapter
+            val chapter = ReadBook.readerChapterInputWindow.current?.chapter
                 ?: currentChapter()
                 ?: return@launch
             val url = chapter.getAbsoluteURL()

+ 18 - 19
app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.read
 import io.legado.app.R
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.repository.BookmarkRepository
+import io.legado.app.feature.reader.core.navigation.ReaderPageContext
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReaderBookmarkState
 import kotlinx.coroutines.CoroutineScope
@@ -45,6 +46,8 @@ class ReadBookmarkDelegate(
     private val toggleMutex = Mutex()
 
     interface Host {
+        val currentCanvasPage: ReaderPageContext?
+
         /** 打开/关闭书签弹层的同时收起阅读菜单。 */
         fun setActiveSheet(sheet: ReadBookSheet?)
 
@@ -87,28 +90,22 @@ class ReadBookmarkDelegate(
     /**
      * 下滑手势:本页无书签则直接存一条(不弹编辑器),已有则删掉离当前阅读位置最近的一条。
      *
-     * 页范围取 `[页首位置, 下一页页首位置)`——末页取到章节已排版长度,
-     * 与 [addForCurrentPage] 存入的 `ReadBook.durChapterPos` 落点一致。
-     * 消息页/空页与角标判定一致地跳过(页首位置取 `textLines.first()`,空页会抛异常)。
+     * 页范围取 `[页首位置, 下一页页首位置)`;Canvas 就绪后直接使用 reader core
+     * 从当前页面元素计算的范围与正文。
+     * 与 [addForCurrentPage] 存入的 `ReadBook.durChapterPos` 落点一致,空页会被跳过。
      * 整体串行化:先查后写不是原子的,快速连滑会双双看到「空」而重复插入。
      */
     fun toggleForCurrentPage() {
         scope.launch(IO) {
             toggleMutex.withLock {
                 val book = ReadBook.book ?: return@withLock
-                val chapter = ReadBook.curTextChapter ?: return@withLock
-                val pageIndex = ReadBook.durPageIndex
-                val page = chapter.getPage(pageIndex) ?: return@withLock
-                if (page.isMsgPage || page.lineSize <= 0) return@withLock
-                val startPos = page.chapterPosition
-                val endPos = chapter.getPage(pageIndex + 1)?.chapterPosition
-                    ?: (startPos + page.charSize)
+                val page = currentPage() ?: return@withLock
                 val existing = bookmarkRepository.getByChapterRange(
                     bookName = book.name,
                     bookAuthor = book.author,
-                    chapterIndex = chapter.chapter.index,
-                    startPos = startPos,
-                    endPos = endPos,
+                    chapterIndex = page.chapterIndex,
+                    startPos = page.startPosition,
+                    endPos = page.endPosition,
                 )
                 if (existing.isEmpty()) {
                     bookmarkRepository.save(
@@ -116,8 +113,8 @@ class ReadBookmarkDelegate(
                             bookName = book.name,
                             bookAuthor = book.author,
                             bookUrl = book.bookUrl,
-                            chapterIndex = chapter.chapter.index,
-                            chapterName = chapter.title,
+                            chapterIndex = page.chapterIndex,
+                            chapterName = page.chapterTitle,
                             chapterPos = ReadBook.durChapterPos,
                             bookText = page.text.replace(BOOK_TEXT_MARKS, "").trim(),
                             content = "",
@@ -144,14 +141,13 @@ class ReadBookmarkDelegate(
     fun addForCurrentPage() {
         scope.launch(IO) {
             val book = ReadBook.book ?: return@launch
-            val chapter = ReadBook.curTextChapter ?: return@launch
-            val page = chapter.pages.getOrNull(ReadBook.durPageIndex) ?: return@launch
+            val page = currentPage() ?: return@launch
             val bookmark = Bookmark(
                 bookName = book.name,
                 bookAuthor = book.author,
                 bookUrl = book.bookUrl,
-                chapterIndex = chapter.chapter.index,
-                chapterName = chapter.title,
+                chapterIndex = page.chapterIndex,
+                chapterName = page.chapterTitle,
                 chapterPos = ReadBook.durChapterPos,
                 bookText = page.text,
                 content = "",
@@ -181,6 +177,9 @@ class ReadBookmarkDelegate(
         }
     }
 
+    private fun currentPage(): ReaderPageContext? = host.currentCanvasPage
+        ?.takeIf { it.chapterIndex == ReadBook.durChapterIndex }
+
     private companion object {
         /** 与 ReadBookController.addBookmark 一致:剔除正文里的排版占位符。 */
         val BOOK_TEXT_MARKS = Regex("[袮꧁]")

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

@@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.SharedFlow
 /**
  * Typed replacement for the legacy `EventBus.UP_CONFIG` integer-code channel.
  * Producers post [ConfigUpdateAction] sets; [ReadBookViewModel] collects and
- * forwards them to the ReadView as [ReadBookEffect.UpdateReadViewConfig].
+ * forwards them to the active reader renderer as [ReadBookEffect.UpdateReaderConfig].
  */
 object ReadConfigUpdateBus {
 

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt

@@ -644,7 +644,7 @@ class ReadConfigUpdateDelegate(
                     // 等写入落地再发 UpdateStyle,否则 upBookmarkBadge 读到旧尺寸
                     readSettingsRepository.preferences.first { it.bookmarkBadgeSize == update.value }
                     host.emitEffect(
-                        ReadBookEffect.UpdateReadViewConfig(setOf(ConfigUpdateAction.UpdateStyle))
+                        ReadBookEffect.UpdateReaderConfig(setOf(ConfigUpdateAction.UpdateStyle))
                     )
                 }
             }
@@ -757,7 +757,7 @@ class ReadConfigUpdateDelegate(
             host.refreshConfigSnapshots()
         }
         if (update.actions.isNotEmpty()) {
-            host.emitEffect(ReadBookEffect.UpdateReadViewConfig(update.actions))
+            host.emitEffect(ReadBookEffect.UpdateReaderConfig(update.actions))
         }
     }
 

+ 7 - 22
app/src/main/java/io/legado/app/ui/book/read/ReadContentEditDelegate.kt

@@ -2,6 +2,7 @@ package io.legado.app.ui.book.read
 
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.repository.ReadSettingsRepository
+import io.legado.app.feature.reader.core.navigation.ReaderPageContext
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.isLocal
@@ -9,7 +10,6 @@ import io.legado.app.help.book.isLocalTxt
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.ReadBook
 import io.legado.app.model.webBook.WebBook
-import io.legado.app.ui.book.read.page.entities.TextPage
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -32,6 +32,8 @@ class ReadContentEditDelegate(
 ) {
 
     interface Host {
+        val currentCanvasPage: ReaderPageContext?
+
         fun setActiveSheet(sheet: ReadBookSheet?)
 
         suspend fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
@@ -143,29 +145,12 @@ class ReadContentEditDelegate(
 
     // --- 光标定位:优先用打开弹层那一刻的可见首行,其次用锚点文本 ---
 
-    private fun currentPage(): TextPage? {
-        return ReadBook.curTextChapter?.getPage(ReadBook.durPageIndex)
-    }
+    private fun currentPage(): ReaderPageContext? = host.currentCanvasPage
+        ?.takeIf { it.chapterIndex == ReadBook.durChapterIndex }
 
-    private fun currentOffset(): Int {
-        val page = currentPage()
-        return page?.lines
-            ?.firstOrNull { !it.isTitle && it.text.isNotBlank() }
-            ?.chapterPosition
-            ?: page?.lines
-                ?.firstOrNull { !it.isTitle }
-                ?.chapterPosition
-            ?: ReadBook.durChapterPos
-    }
+    private fun currentOffset(): Int = currentPage()?.contentStartPosition ?: ReadBook.durChapterPos
 
-    private fun currentAnchor(): String? {
-        return currentPage()
-            ?.lines
-            ?.firstOrNull { !it.isTitle && it.text.isNotBlank() }
-            ?.text
-            ?.trim()
-            ?.takeIf { it.isNotEmpty() }
-    }
+    private fun currentAnchor(): String? = currentPage()?.anchorText
 
     private fun resolveCursorOffset(text: String): Int {
         if (text.isEmpty()) {

+ 0 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadHighlightRuleDelegate.kt

@@ -15,7 +15,6 @@ import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.text
-import io.legado.app.ui.book.read.page.provider.TextChapterLayout
 import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
 import io.legado.app.ui.widget.components.importComponents.ImportStatus
@@ -128,7 +127,6 @@ class ReadHighlightRuleDelegate(
         val rule = _uiState.value.deleteRule ?: return
         val configName = ReadBookConfig.durConfig.name
         highlightRuleRepository.delete(rule)
-        TextChapterLayout.invalidateRegexCache()
         _uiState.update {
             it.copy(
                 rules = highlightRuleRepository.load(configName).toImmutableList(),
@@ -155,7 +153,6 @@ class ReadHighlightRuleDelegate(
         val configName = ReadBookConfig.durConfig.name
         val sanitizedRules = rules.map(highlightRuleRepository::sanitizeRule)
         highlightRuleRepository.save(configName, sanitizedRules)
-        TextChapterLayout.invalidateRegexCache()
         _uiState.update {
             it.copy(
                 rules = highlightRuleRepository.load(configName).toImmutableList(),

+ 0 - 144
app/src/main/java/io/legado/app/ui/book/read/ReadSearchResultLocator.kt

@@ -1,144 +0,0 @@
-package io.legado.app.ui.book.read
-
-import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.entities.TextPage
-import io.legado.app.ui.book.searchContent.SearchResult
-
-/**
- * 把一条 [SearchResult] 定位到当前章排版结果里的 (页, 行, 列)。
- *
- * 这段是纯函数——只吃 (TextChapter, SearchResult, query),不碰任何 UI 状态,
- * 所以从 `ReadBookViewModel` 摘出来独立成对象,[findMatch] 可以直接写单测覆盖。
- * `ReadBookViewModel.searchResultPositions` 保留为转发入口,供 `ReadBookController` 调用。
- */
-internal object ReadSearchResultLocator {
-
-    /** @return `[pageIndex, lineIndex, charIndex, pageSpan, endLineIndex, endCharIndex]`;定位失败首位为 -1。 */
-    fun positions(
-        textChapter: TextChapter,
-        searchResult: SearchResult,
-        query: String,
-    ): Array<Int> {
-        val pages = textChapter.pages
-        val content = textChapter.getContent()
-        if (pages.isEmpty()) return NOT_FOUND
-
-        val match = findMatch(content, searchResult, query) ?: return NOT_FOUND
-        val contentPosition = match.first
-        val queryLength = match.second
-        if (contentPosition < 0 || queryLength <= 0) {
-            return NOT_FOUND
-        }
-
-        val start = findTextPoint(pages, contentPosition, preferPreviousLine = false)
-            ?: return NOT_FOUND
-        val end = findTextPoint(
-            pages,
-            contentPosition + queryLength - 1,
-            preferPreviousLine = true
-        ) ?: start
-        return arrayOf(
-            start.pageIndex,
-            start.lineIndex,
-            start.charIndex,
-            end.pageIndex - start.pageIndex,
-            end.lineIndex,
-            end.charIndex
-        )
-    }
-
-    /**
-     * 在章节正文里找到这条结果对应的 `位置 to 长度`。
-     *
-     * 先信 [SearchResult.queryIndexInChapter] 这个直达下标(校验后才用),校验不过再退回
-     * 「数到第 [SearchResult.resultCountWithinChapter] 次出现」的扫描。
-     */
-    internal fun findMatch(
-        content: String,
-        searchResult: SearchResult,
-        query: String,
-    ): Pair<Int, Int>? {
-        if (query.isEmpty()) return null
-        val directLength = if (searchResult.matchLength > 0) searchResult.matchLength else query.length
-        val directIndex = searchResult.queryIndexInChapter
-        if (directIndex >= 0 && directIndex + directLength <= content.length) {
-            val directMatch = if (searchResult.isRegex) {
-                runCatching {
-                    Regex(query).matches(content.substring(directIndex, directIndex + directLength))
-                }.getOrDefault(false)
-            } else {
-                content.regionMatches(
-                    directIndex,
-                    query,
-                    0,
-                    query.length,
-                    ignoreCase = false
-                )
-            }
-            if (directMatch) {
-                return directIndex to directLength
-            }
-        }
-        if (searchResult.isRegex) {
-            return runCatching {
-                Regex(query).findAll(content)
-                    .drop(searchResult.resultCountWithinChapter)
-                    .firstOrNull()
-                    ?.let { it.range.first to it.value.length }
-            }.getOrNull()
-        }
-
-        var count = 0
-        var index = content.indexOf(query)
-        while (count != searchResult.resultCountWithinChapter && index >= 0) {
-            index = content.indexOf(query, index + query.length)
-            count += 1
-        }
-        return index.takeIf { it >= 0 }?.let { it to query.length }
-    }
-
-    private fun findTextPoint(
-        pages: List<TextPage>,
-        contentPosition: Int,
-        preferPreviousLine: Boolean,
-    ): SearchTextPoint? {
-        var fallback: SearchTextPoint? = null
-        pages.forEachIndexed { pageIndex, page ->
-            page.lines.forEachIndexed { lineIndex, line ->
-                if (line.columns.isEmpty()) return@forEachIndexed
-                val lineStart = line.chapterPosition
-                val lineEndExclusive = lineStart + line.charSize
-                if (contentPosition in lineStart until lineEndExclusive) {
-                    return SearchTextPoint(
-                        pageIndex = pageIndex,
-                        lineIndex = lineIndex,
-                        charIndex = (contentPosition - lineStart).coerceIn(0, line.columns.lastIndex)
-                    )
-                }
-                if (preferPreviousLine && line.isParagraphEnd && contentPosition == lineEndExclusive) {
-                    return SearchTextPoint(
-                        pageIndex = pageIndex,
-                        lineIndex = lineIndex,
-                        charIndex = line.columns.lastIndex
-                    )
-                }
-                if (contentPosition >= lineEndExclusive) {
-                    fallback = SearchTextPoint(
-                        pageIndex = pageIndex,
-                        lineIndex = lineIndex,
-                        charIndex = line.columns.lastIndex
-                    )
-                }
-            }
-        }
-        return fallback
-    }
-
-    private val NOT_FOUND get() = arrayOf(-1, 0, 0, 0, 0, 0)
-
-    private data class SearchTextPoint(
-        val pageIndex: Int,
-        val lineIndex: Int,
-        val charIndex: Int,
-    )
-}

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

@@ -486,7 +486,7 @@ class ReadStyleDelegate(
     }
 
     private fun emitConfigUpdate(vararg actions: ConfigUpdateAction) {
-        host.emitEffect(ReadBookEffect.UpdateReadViewConfig(actions.toSet()))
+        host.emitEffect(ReadBookEffect.UpdateReaderConfig(actions.toSet()))
     }
 
     private companion object {

+ 13 - 2
app/src/main/java/io/legado/app/ui/book/read/ReaderFirstFrame.kt

@@ -6,6 +6,17 @@ import io.legado.app.BuildConfig
 import java.util.concurrent.atomic.AtomicBoolean
 
 internal const val EXTRA_FIRST_FRAME_STARTED_AT_NANOS = "readerFirstFrameStartedAtNanos"
+internal const val READER_RENDERER_NAME = "compose-canvas"
+
+internal enum class ReaderStartupFramePhase(val logValue: String) {
+    LOADING("loading"),
+    CONTENT("content"),
+}
+
+internal fun readerFirstFrameLogMessage(
+    durationMillis: Float,
+    phase: ReaderStartupFramePhase = ReaderStartupFramePhase.CONTENT,
+): String = "renderer=$READER_RENDERER_NAME phase=${phase.logValue} durationMs=$durationMillis"
 
 /**
  * Debug-only 首帧探针:从 intent 传入的启动时刻到正文第一次绘制之间的耗时,
@@ -16,10 +27,10 @@ internal class ReaderFirstFrameTracker(
 ) {
     private val reported = AtomicBoolean(false)
 
-    fun report() {
+    fun report(phase: ReaderStartupFramePhase = ReaderStartupFramePhase.CONTENT) {
         if (!BuildConfig.DEBUG) return
         if (!reported.compareAndSet(false, true)) return
         val durationMillis = (SystemClock.elapsedRealtimeNanos() - startedAtNanos) / 1_000_000f
-        Log.i("ReaderFirstFrame", "renderer=legacy durationMs=$durationMillis")
+        Log.i("ReaderFirstFrame", readerFirstFrameLogMessage(durationMillis, phase))
     }
 }

+ 45 - 0
app/src/main/java/io/legado/app/ui/book/read/ReaderRenderUiState.kt

@@ -0,0 +1,45 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import io.legado.app.feature.reader.core.model.ReaderPageWindow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Minimal, atomically published state consumed by the Compose reading surface. */
+@Stable
+data class ReaderRenderUiState(
+    val pageWindow: ReaderPageWindow = ReaderPageWindow(),
+    val paginationError: String? = null,
+    val background: ReaderBackgroundState = ReaderBackgroundState(),
+)
+
+/** Lightweight state owner for the hot Canvas path only. */
+class ReaderSessionViewModel : ViewModel() {
+    private val _uiState = MutableStateFlow(ReaderRenderUiState())
+    val uiState = _uiState.asStateFlow()
+    private var pendingState = _uiState.value
+    private var entranceSettled = false
+
+    fun submit(state: ReaderRenderUiState) {
+        pendingState = state
+        if (entranceSettled) _uiState.value = state
+    }
+
+    /** Background participates in shared-bounds rendering and must not wait for page commit. */
+    fun submitBackground(background: ReaderBackgroundState) {
+        pendingState = pendingState.copy(background = background)
+        _uiState.value = _uiState.value.copy(background = background)
+    }
+
+    /** A prepared page may start fading in while the shared-bounds transition is still running. */
+    fun submitPageWindow(pageWindow: ReaderPageWindow) {
+        pendingState = pendingState.copy(pageWindow = pageWindow)
+        _uiState.value = _uiState.value.copy(pageWindow = pageWindow)
+    }
+
+    fun onEntranceStateChanged(settled: Boolean) {
+        entranceSettled = settled
+        if (settled) _uiState.value = pendingState
+    }
+}

+ 1 - 2
app/src/main/java/io/legado/app/ui/book/read/ReaderViewport.kt

@@ -65,8 +65,7 @@ interface ReaderLayoutController {
 }
 
 /**
- * Track C · C4 的尺寸输入接缝。分页仍由 ChapterProvider 完成;本类只负责把
- * View/Compose 的测量结果统一成正文尺寸,并暴露排版完成 revision。
+ * Publishes the Compose viewport to the Canvas pagination environment and exposes layout revisions.
  */
 internal class ReaderLayoutCoordinator(
     private val updateLayoutSize: (width: Int, height: Int) -> Unit,

+ 0 - 161
app/src/main/java/io/legado/app/ui/book/read/config/BgImageSpan.kt

@@ -1,161 +0,0 @@
-package io.legado.app.ui.book.read.config
-
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.graphics.BitmapShader
-import android.graphics.Canvas
-import android.graphics.Matrix
-import android.graphics.Paint
-import android.graphics.RectF
-import android.graphics.Shader
-import android.text.style.ReplacementSpan
-import io.legado.app.ui.book.read.page.entities.TextLine
-import io.legado.app.utils.dpToPx
-
-/**
- * 背景图+下划线 Span,用于高亮规则匹配区域
- */
-class BgImageSpan(
-    private val textColor: Int,
-    private val bgImagePath: String,
-    private val bgImageFit: Int = 0,
-    private val bgImageScale: Float = 1f,
-    private val underlineMode: Int = 0,
-    private val underlineColor: Int = 0,
-    private val underlineWidth: Float = 1f,
-    private val underlineSvgPath: String = "",
-    private val underlineOffset: Float = 6f,
-) : ReplacementSpan() {
-
-    private val offsetPx = underlineOffset.toInt().dpToPx()
-
-    override fun getSize(
-        paint: Paint,
-        text: CharSequence,
-        start: Int,
-        end: Int,
-        fm: Paint.FontMetricsInt?
-    ): Int {
-        if (fm != null) {
-            val metrics = paint.fontMetricsInt
-            fm.top = metrics.top
-            fm.ascent = metrics.ascent
-            fm.descent = metrics.descent + if (underlineMode != 0) offsetPx else 0
-            fm.bottom = metrics.bottom + if (underlineMode != 0) offsetPx else 0
-        }
-        return paint.measureText(text, start, end).toInt()
-    }
-
-    override fun draw(
-        canvas: Canvas,
-        text: CharSequence,
-        start: Int,
-        end: Int,
-        x: Float,
-        top: Int,
-        y: Int,
-        bottom: Int,
-        paint: Paint
-    ) {
-        val width = paint.measureText(text, start, end)
-        val rectWidth = width
-        val rectHeight = (bottom - top).toFloat()
-        val scale = bgImageScale.coerceIn(0.1f, 5f)
-
-        val bitmap = TextLine.getBgBitmap(bgImagePath)
-        if (bitmap != null) {
-            val bgPaint = Paint().apply {
-                style = Paint.Style.FILL
-                isAntiAlias = true
-                isFilterBitmap = true
-            }
-            when (bgImageFit) {
-                1 -> {
-                    val sw = rectWidth * scale
-                    val sh = rectHeight * scale
-                    val dx = x + (rectWidth - sw) / 2f
-                    val dy = top + (rectHeight - sh) / 2f
-                    canvas.save()
-                    canvas.clipRect(x, top.toFloat(), x + width, bottom.toFloat())
-                    canvas.drawBitmap(bitmap, null, RectF(dx, dy, dx + sw, dy + sh), bgPaint)
-                    canvas.restore()
-                }
-                2 -> {
-                    val bw = bitmap.width.toFloat()
-                    val bh = bitmap.height.toFloat()
-                    val fitScale = (rectWidth / bw).coerceAtLeast(rectHeight / bh) * scale
-                    val scaledW = bw * fitScale
-                    val scaledH = bh * fitScale
-                    val dx = x + (rectWidth - scaledW) / 2f
-                    val dy = top + (rectHeight - scaledH) / 2f
-                    canvas.save()
-                    canvas.clipRect(x, top.toFloat(), x + width, bottom.toFloat())
-                    canvas.drawBitmap(bitmap, null, RectF(dx, dy, dx + scaledW, dy + scaledH), bgPaint)
-                    canvas.restore()
-                }
-                else -> {
-                    val shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
-                    val matrix = Matrix()
-                    if (scale != 1f) {
-                        matrix.setScale(scale, scale)
-                    }
-                    matrix.postTranslate(x, top.toFloat())
-                    shader.setLocalMatrix(matrix)
-                    bgPaint.shader = shader
-                    canvas.drawRect(x, top.toFloat(), x + width, bottom.toFloat(), bgPaint)
-                }
-            }
-        }
-
-        paint.color = textColor
-        paint.shader = null
-        canvas.drawText(text, start, end, x, y.toFloat(), paint)
-
-        if (underlineMode != 0) {
-            drawUnderline(canvas, x, x + width, y + offsetPx, paint)
-        }
-    }
-
-    private fun drawUnderline(canvas: Canvas, startX: Float, endX: Float, lineY: Int, paint: Paint) {
-        val ulPaint = Paint(paint).apply {
-            color = underlineColor
-            style = Paint.Style.STROKE
-            strokeWidth = underlineWidth.dpToPx()
-            isAntiAlias = true
-        }
-        when (underlineMode) {
-            1 -> canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint)
-            2 -> {
-                ulPaint.pathEffect = android.graphics.DashPathEffect(floatArrayOf(10f, 10f), 0f)
-                canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint)
-            }
-            3 -> {
-                val path = android.graphics.Path()
-                val waveAmplitude = 3.dpToPx().toFloat()
-                val waveLength = 12.dpToPx().toFloat()
-                path.moveTo(startX, lineY.toFloat())
-                var currentX = startX
-                val endY = lineY.toFloat()
-                while (currentX < endX) {
-                    val nextX = (currentX + waveLength).coerceAtMost(endX)
-                    val midX = (currentX + nextX) / 2
-                    path.quadTo(midX, endY - waveAmplitude, nextX, endY)
-                    currentX = nextX
-                    if (currentX < endX) {
-                        val nextX2 = (currentX + waveLength).coerceAtMost(endX)
-                        val midX2 = (currentX + nextX2) / 2
-                        path.quadTo(midX2, endY + waveAmplitude, nextX2, endY)
-                        currentX = nextX2
-                    }
-                }
-                canvas.drawPath(path, ulPaint)
-            }
-            4 -> {
-                val lineGap = 3.dpToPx()
-                val line2Y = lineY + lineGap + underlineWidth.dpToPx()
-                canvas.drawLine(startX, lineY.toFloat(), endX, lineY.toFloat(), ulPaint)
-                canvas.drawLine(startX, line2Y.toFloat(), endX, line2Y.toFloat(), ulPaint)
-            }
-        }
-    }
-}

+ 0 - 168
app/src/main/java/io/legado/app/ui/book/read/page/AutoPager.kt

@@ -1,168 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-import android.graphics.Canvas
-import android.graphics.Paint
-import android.os.SystemClock
-import androidx.core.graphics.withClip
-import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.ui.book.read.page.entities.PageDirection
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.utils.canvasrecorder.CanvasRecorderFactory
-import io.legado.app.utils.canvasrecorder.recordIfNeeded
-import io.legado.app.utils.themeColor
-
-/**
- * 自动翻页
- */
-class AutoPager(private val readView: ReadView) : Runnable {
-    private var progress = 0
-    var isRunning = false
-        private set
-    private var isPausing = false
-    private var isEInkMode = false
-    private var scrollOffsetRemain = 0.0
-    private var scrollOffset = 0
-    private var lastTimeMillis = 0L
-    private var canvasRecorder = CanvasRecorderFactory.create()
-    private val paint by lazy { Paint() }
-    var onStop: (() -> Unit)? = null
-
-
-    fun start() {
-        isRunning = true
-        isEInkMode = ReadConfig.isEInkMode
-        readView.curPage.upSelectAble(false)
-        if (isEInkMode) {
-            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
-        } else {
-            paint.color = readView.context.themeColor(androidx.appcompat.R.attr.colorPrimary)
-            lastTimeMillis = SystemClock.uptimeMillis()
-            readView.invalidate()
-        }
-    }
-
-    fun stop() {
-        if (!isRunning) {
-            return
-        }
-        isRunning = false
-        isPausing = false
-        isEInkMode = false
-        readView.removeCallbacks(this)
-        readView.curPage.upSelectAble(ReadConfig.selectText)
-        readView.invalidate()
-        reset()
-        canvasRecorder.recycle()
-        onStop?.invoke()
-    }
-
-    fun pause() {
-        if (!isRunning) {
-            return
-        }
-        isPausing = true
-        readView.removeCallbacks(this)
-    }
-
-    fun resume() {
-        if (!isRunning) {
-            return
-        }
-        isPausing = false
-        if (isEInkMode) {
-            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
-        } else {
-            lastTimeMillis = SystemClock.uptimeMillis()
-            readView.invalidate()
-        }
-    }
-
-    fun reset() {
-        if (isEInkMode) {
-            readView.removeCallbacks(this)
-            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
-        } else {
-            progress = 0
-            scrollOffsetRemain = 0.0
-            scrollOffset = 0
-            canvasRecorder.invalidate()
-        }
-    }
-
-    fun upRecorder() {
-        canvasRecorder.recycle()
-        canvasRecorder = CanvasRecorderFactory.create()
-    }
-
-    fun onDraw(canvas: Canvas) {
-        if (!isRunning || isEInkMode) {
-            return
-        }
-
-        if (readView.isScroll) {
-            if (!isPausing) {
-                readView.curPage.scroll(-scrollOffset)
-                scrollOffset = 0
-            }
-        } else {
-            val bottom = progress
-            val width = readView.width
-
-            canvasRecorder.recordIfNeeded(readView.nextPage)
-            canvas.withClip(0, 0, width, bottom) {
-                canvasRecorder.draw(this)
-            }
-
-            canvas.drawRect(
-                0f,
-                bottom.toFloat() - 1,
-                width.toFloat(),
-                bottom.toFloat(),
-                paint
-            )
-            if (!isPausing) readView.postInvalidate()
-        }
-
-    }
-
-    fun computeOffset() {
-        if (!isRunning || isPausing || isEInkMode) {
-            return
-        }
-
-        val currentTime = SystemClock.uptimeMillis()
-        val elapsedTime = currentTime - lastTimeMillis
-        lastTimeMillis = currentTime
-
-        val readTime = ReadBookConfig.autoReadSpeed * 1000.0
-        val height = readView.height
-        scrollOffsetRemain += height / readTime * elapsedTime
-        if (scrollOffsetRemain < 1) {
-            return
-        }
-        scrollOffset = scrollOffsetRemain.toInt()
-        this.scrollOffsetRemain -= scrollOffset
-        if (!readView.isScroll) {
-            progress += scrollOffset
-            if (progress >= height) {
-                if (!readView.fillPage(PageDirection.NEXT)) {
-                    stop()
-                } else {
-                    reset()
-                }
-            }
-        }
-    }
-
-    override fun run() {
-        if (!isRunning || isPausing) {
-            return
-        }
-        if (!readView.fillPage(PageDirection.NEXT)) {
-            stop()
-        } else {
-            readView.postDelayed(this, ReadBookConfig.autoReadSpeed * 1000L)
-        }
-    }
-
-}

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

@@ -1,809 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Paint
-import android.util.AttributeSet
-import android.view.MotionEvent
-import android.view.View
-import io.legado.app.R
-import io.legado.app.data.entities.Bookmark
-import io.legado.app.help.book.isOnLineTxt
-import io.legado.app.model.ReadBook
-import io.legado.app.ui.association.OpenUrlConfirmActivity
-import io.legado.app.ui.book.read.ReaderLayoutController
-import io.legado.app.ui.book.read.ReaderViewport
-import io.legado.app.ui.book.read.page.delegate.PageDelegate
-import io.legado.app.ui.book.read.page.entities.TextLine
-import io.legado.app.ui.book.read.page.entities.TextPage
-import io.legado.app.ui.book.read.page.entities.TextPos
-import io.legado.app.ui.book.read.page.entities.column.BaseColumn
-import io.legado.app.ui.book.read.page.entities.column.ButtonColumn
-import io.legado.app.ui.book.read.page.entities.column.ImageColumn
-import io.legado.app.ui.book.read.page.entities.column.ReviewColumn
-import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
-import io.legado.app.ui.book.read.page.entities.column.TextColumn
-import io.legado.app.ui.book.read.page.entities.column.TextHtmlColumn
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.book.read.page.provider.TextPageFactory
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.ui.main.MainNavigator
-import io.legado.app.ui.widget.dialog.PhotoDialog
-import io.legado.app.utils.activity
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.getCompatColor
-import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.toastOnUi
-import java.util.concurrent.Executors
-import kotlin.math.max
-import kotlin.math.min
-
-/**
- * 阅读内容视图
- */
-class ContentTextView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
-    var selectAble = ReadConfig.selectText
-    val selectedPaint by lazy {
-        Paint().apply {
-            color = context.getCompatColor(R.color.btn_bg_press_2)
-            style = Paint.Style.FILL
-        }
-    }
-    private var callBack: CallBack? = null
-    private val requireCallBack: CallBack
-        get() = callBack ?: activity as CallBack
-    // 每次读取——ChapterProvider 的排版度量是不可变快照,重新排版会换成新的 RectF 实例,
-    // 捕获引用会永远停在旧尺寸上。
-    private val visibleRect get() = ChapterProvider.visibleRect
-    val selectStart = TextPos(0, -1, -1)
-    private val selectEnd = TextPos(0, -1, -1)
-    val selectEndPosition: TextPos get() = selectEnd
-    var textPage: TextPage = TextPage()
-        private set
-    var isMainView = false
-    var longScreenshot = false
-    var reverseStartCursor = false
-    var reverseEndCursor = false
-
-    //滚动参数
-    private val pageFactory get() = requireCallBack.pageFactory
-    private val pageDelegate get() = requireCallBack.pageDelegate
-    private var pageOffset = 0
-    private var autoPager: AutoPager? = null
-    private var isScroll = false
-    private val renderRunnable by lazy { Runnable { preRenderPage() } }
-    private var lastClickTime = 0L
-    private var doubleClick = false
-
-    //绘制图片的paint
-    val imagePaint by lazy {
-        Paint().apply {
-            isAntiAlias = ReadConfig.useAntiAlias
-        }
-    }
-
-    fun setCallBack(callBack: CallBack) {
-        this.callBack = callBack
-    }
-
-    /**
-     * 设置内容
-     */
-    fun setContent(textPage: TextPage) {
-        this.textPage = textPage
-        // 非滑动翻页动画需要同步重绘,不然翻页可能会出现闪烁
-        if (isScroll) {
-            postInvalidate()
-        } else {
-            invalidate()
-        }
-    }
-
-    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
-        super.onSizeChanged(w, h, oldw, oldh)
-        if (!isMainView || MainNavigator.backNavigationInProgress) return
-        requireCallBack.readerLayoutController.updateViewport(
-            ReaderViewport(
-                widthPx = w,
-                heightPx = h,
-                density = resources.displayMetrics.density,
-            )
-        )
-        textPage.format()
-    }
-
-    override fun onDraw(canvas: Canvas) {
-        super.onDraw(canvas)
-        autoPager?.onDraw(canvas)
-        if (longScreenshot) {
-            canvas.translate(0f, scrollY.toFloat())
-        }
-        check(!visibleRect.isEmpty) { "visibleRect 为空" }
-        canvas.clipRect(visibleRect)
-        drawPage(canvas)
-    }
-
-    /**
-     * 绘制页面
-     */
-    private fun drawPage(canvas: Canvas) {
-        var relativeOffset = relativeOffset(0)
-        textPage.draw(this, canvas, relativeOffset)
-        if (!requireCallBack.isScroll) return
-        //滚动翻页
-        if (!pageFactory.hasNext()) return
-        val textPage1 = relativePage(1)
-        relativeOffset += textPage.height
-        textPage1.draw(this, canvas, relativeOffset)
-        if (!pageFactory.hasNextPlus()) return
-        relativeOffset += textPage1.height
-        if (relativeOffset < ChapterProvider.visibleHeight) {
-            val textPage2 = relativePage(2)
-            textPage2.draw(this, canvas, relativeOffset)
-        }
-    }
-
-    override fun computeScroll() {
-        pageDelegate?.computeScroll()
-        autoPager?.computeOffset()
-    }
-
-    /**
-     * 滚动事件
-     * pageOffset 向上滚动 减小 向下滚动 增大
-     * pageOffset 范围 0 ~ -textPage.height 大于0为上一页,小于-textPage.height为下一页
-     * 以内容显示区域顶端为界,pageOffset的绝对值为textPage上方的高度
-     * pageOffset + textPage.height 为 textPage 下方的高度
-     */
-    fun scroll(mOffset: Int) {
-        pageOffset += mOffset
-        if (longScreenshot) {
-            scrollY += -mOffset
-        }
-        if (!pageFactory.hasPrev() && pageOffset > 0) {
-            pageOffset = 0
-            pageDelegate?.abortAnim()
-        } else if (!pageFactory.hasNext()
-            && pageOffset < 0
-            && pageOffset + textPage.height < ChapterProvider.visibleHeight
-        ) {
-            val offset = (ChapterProvider.visibleHeight - textPage.height).toInt()
-            pageOffset = min(0, offset)
-            pageDelegate?.abortAnim()
-        } else if (pageOffset > 0) {
-            if (pageFactory.moveToPrev(true)) {
-                pageOffset -= textPage.height.toInt()
-            } else {
-                pageOffset = 0
-                pageDelegate?.abortAnim()
-            }
-        } else if (pageOffset < -textPage.height) {
-            val height = textPage.height
-            if (pageFactory.moveToNext(upContent = true)) {
-                pageOffset += height.toInt()
-            } else {
-                pageOffset = -height.toInt()
-                pageDelegate?.abortAnim()
-            }
-        }
-        postInvalidate()
-    }
-
-    fun submitRenderTask() {
-        renderThread.submit(renderRunnable)
-    }
-
-    private fun preRenderPage() {
-        val view = this
-        var invalidate = false
-        pageFactory.run {
-            if (hasPrev() && prevPage.render(view)) {
-                invalidate = true
-            }
-            if (curPage.render(view)) {
-                invalidate = true
-            }
-            if (hasNext() && nextPage.render(view) && requireCallBack.isScroll) {
-                invalidate = true
-            }
-            if (hasNextPlus() && nextPlusPage.render(view) && requireCallBack.isScroll
-                && relativeOffset(2) < ChapterProvider.visibleHeight
-            ) {
-                invalidate = true
-            }
-            if (invalidate) {
-                postInvalidate()
-                pageDelegate?.postInvalidate()
-            }
-        }
-    }
-
-    /**
-     * 重置滚动位置
-     */
-    fun resetPageOffset() {
-        pageOffset = 0
-    }
-
-    /**
-     * 长按
-     */
-    fun longPress(
-        x: Float,
-        y: Float,
-        select: (textPos: TextPos) -> Unit,
-    ) {
-        touch(x, y) { _, textPos, _, _, column ->
-            when (column) {
-                is ImageColumn -> requireCallBack.onImageLongPress(x, y, column.src)
-                is TextColumn -> {
-                    if (!selectAble) return@touch
-                    column.selected = true
-                    select(textPos)
-                }
-                is TextHtmlColumn -> {
-                    if (!selectAble) return@touch
-                    column.selected = true
-                    select(textPos)
-                }
-            }
-        }
-    }
-
-    /**
-     * 单击
-     * @return true:已处理, false:未处理
-     */
-    @Suppress("UNUSED_ANONYMOUS_PARAMETER")
-    fun click(x: Float, y: Float): Boolean {
-        val currentTime = System.currentTimeMillis()
-        val debounceClick = currentTime - lastClickTime < 300L //300毫秒防抖和双击
-        lastClickTime = currentTime
-        doubleClick = if (debounceClick) {
-            !doubleClick
-        } else {
-            false
-        }
-        var handled = false
-        touch(x, y) { _, textPos, textPage, textLine, column ->
-            when (column) {
-                is ButtonColumn -> {
-                    context.toastOnUi("Button Pressed!")
-                    handled = true
-                }
-
-                is ReviewColumn -> {
-                    context.toastOnUi("Button Pressed!")
-                    handled = true
-                }
-
-                is ImageColumn -> when (ReadConfig.clickImgWay) {
-                    "1" -> { //预览图片
-                        activity?.showDialogFragment(PhotoDialog(column.src, isBook = true))
-                        handled = true
-                    }
-                    "2" -> { //兼容处理
-                        if (!debounceClick) {
-                            if (ReadBook.book?.isOnLineTxt == true) {
-                                val click = column.click
-                                val src = column.src
-                                if (!click.isNullOrBlank()) {
-                                    requireCallBack.clickImg(click, src)
-                                    handled = true
-                                } else {
-                                    handled = requireCallBack.oldClickImg(src)
-                                }
-                            }
-                        }
-                    }
-                    "3" -> { //关闭
-                        handled = false
-                    }
-                    "4" -> { //双击
-                        if (doubleClick) {
-                            val click = column.click
-                            if (!click.isNullOrBlank()) {
-                                requireCallBack.clickImg(click, column.src)
-                                handled = true
-                            }
-                        } else {
-                            handled = true
-                        }
-                    }
-                    else -> { //默认点击
-                        if (!debounceClick) {
-                            val click = column.click
-                            if (!click.isNullOrBlank()) {
-                                requireCallBack.clickImg(click, column.src)
-                                handled = true
-                            }
-                        }
-                    }
-                }
-                is TextHtmlColumn -> {
-                    column.linkUrl?.let {
-                        activity?.startActivity<OpenUrlConfirmActivity> {
-                            putExtra("uri", it)
-                        }
-                        handled = true
-                    }
-                }
-                is TextColumn -> {
-                    column.markingId?.let { markingId ->
-                        requireCallBack.onMarkingClick(markingId)
-                        handled = true
-                    }
-                }
-            }
-        }
-        return handled
-    }
-
-    /**
-     * 选择文字
-     */
-    fun selectText(
-        x: Float,
-        y: Float,
-        select: (textPos: TextPos) -> Unit,
-    ) {
-        touchRough(x, y) { _, textPos, _, _, column ->
-            if (column is TextBaseColumn) {
-                column.selected = true
-                select(textPos)
-            }
-        }
-    }
-
-    /**
-     * 开始选择符移动
-     */
-    fun selectStartMove(x: Float, y: Float) {
-        touchRough(x, y) { _, textPos, _, _, _ ->
-            if (selectStart.compare(textPos) == 0) {
-                return@touchRough
-            }
-            if (textPos.compare(selectEnd) <= 0) {
-                selectStartMoveIndex(textPos)
-            } else {
-                touchRough(x - 2 * cursorWidth, y) { _, textPos, _, _, _ ->
-                    if (textPos.compare(selectEnd) > 0) {
-                        reverseStartCursor = true
-                        reverseEndCursor = false
-                        selectEnd.columnIndex++
-                        selectStartMoveIndex(selectEnd)
-                        selectEndMoveIndex(textPos)
-                    }
-                }
-            }
-        }
-    }
-
-    /**
-     * 结束选择符移动
-     */
-    fun selectEndMove(x: Float, y: Float) {
-        touchRough(x, y) { _, textPos, _, _, _ ->
-            if (textPos.compare(selectEnd) == 0) {
-                return@touchRough
-            }
-            if (textPos.compare(selectStart) >= 0) {
-                selectEndMoveIndex(textPos)
-            } else {
-                touchRough(x + 2 * cursorWidth, y) { _, textPos, _, _, _ ->
-                    if (textPos.compare(selectStart) < 0) {
-                        reverseEndCursor = true
-                        reverseStartCursor = false
-                        selectStart.columnIndex--
-                        selectEndMoveIndex(selectStart)
-                        selectStartMoveIndex(textPos)
-                    }
-                }
-            }
-        }
-    }
-
-    /**
-     * 触碰位置信息
-     * @param touched 回调
-     */
-    private fun touch(
-        x: Float,
-        y: Float,
-        touched: (
-            relativeOffset: Float,
-            textPos: TextPos,
-            textPage: TextPage,
-            textLine: TextLine,
-            column: BaseColumn
-        ) -> Unit
-    ) {
-        if (!visibleRect.contains(x, y)) return
-        var relativeOffset: Float
-        for (relativePos in 0..2) {
-            relativeOffset = relativeOffset(relativePos)
-            if (relativePos > 0) {
-                //滚动翻页
-                if (!requireCallBack.isScroll) return
-                if (relativeOffset >= ChapterProvider.visibleHeight) return
-            }
-            val textPage = relativePage(relativePos)
-            for ((lineIndex, textLine) in textPage.lines.withIndex()) {
-                if (textLine.isTouch(x, y, relativeOffset)) {
-                    for ((charIndex, textColumn) in textLine.columns.withIndex()) {
-                        if (textColumn.isTouch(x)) {
-                            touched.invoke(
-                                relativeOffset,
-                                TextPos(relativePos, lineIndex, charIndex),
-                                textPage, textLine, textColumn
-                            )
-                            return
-                        }
-                    }
-                    return
-                }
-            }
-        }
-    }
-
-    /**
-     * 触碰位置信息
-     * 文本选择专用
-     * @param touched 回调
-     */
-    private fun touchRough(
-        x: Float,
-        y: Float,
-        touched: (
-            relativeOffset: Float,
-            textPos: TextPos,
-            textPage: TextPage,
-            textLine: TextLine,
-            column: BaseColumn
-        ) -> Unit
-    ) {
-        var relativeOffset: Float
-        for (relativePos in 0..2) {
-            relativeOffset = relativeOffset(relativePos)
-            if (relativePos > 0) {
-                //滚动翻页
-                if (!requireCallBack.isScroll) return
-                if (relativeOffset >= ChapterProvider.visibleHeight) return
-            }
-            val textPage = relativePage(relativePos)
-            for (lineIndex in textPage.lines.indices) {
-                val textLine = textPage.getLine(lineIndex)
-                if (textLine.isTouchY(y, relativeOffset)) {
-                    if (textPage.doublePage) {
-                        val halfWidth = width / 2
-                        if (textLine.isLeftLine && x > halfWidth) {
-                            continue
-                        }
-                        if (!textLine.isLeftLine && x < halfWidth) {
-                            continue
-                        }
-                    }
-                    val columns = textLine.columns
-                    for (charIndex in columns.indices) {
-                        val textColumn = columns[charIndex]
-                        if (textColumn.isTouch(x)) {
-                            touched.invoke(
-                                relativeOffset,
-                                TextPos(relativePos, lineIndex, charIndex),
-                                textPage, textLine, textColumn
-                            )
-                            return
-                        }
-                    }
-                    val isLast = columns.first().start < x
-                    val charIndex = if (isLast) columns.lastIndex + 1 else -1
-                    val textColumn = if (isLast) columns.last() else columns.first()
-                    touched.invoke(
-                        relativeOffset,
-                        TextPos(relativePos, lineIndex, charIndex),
-                        textPage, textLine, textColumn
-                    )
-                    return
-                }
-            }
-        }
-    }
-
-    fun getCurVisiblePage(): TextPage {
-        val visiblePage = TextPage()
-        var relativeOffset: Float
-        for (relativePos in 0..2) {
-            relativeOffset = relativeOffset(relativePos)
-            if (relativePos > 0) {
-                //滚动翻页
-                if (!requireCallBack.isScroll) break
-                if (relativeOffset >= ChapterProvider.visibleHeight) break
-            }
-            val textPage = relativePage(relativePos)
-            val lines = textPage.lines
-            for (i in lines.indices) {
-                val textLine = lines[i]
-                if (textLine.isVisible(relativeOffset)) {
-                    val visibleLine = textLine.copy().apply {
-                        lineTop += relativeOffset
-                        lineBottom += relativeOffset
-                    }
-                    visiblePage.addLine(visibleLine)
-                }
-            }
-        }
-        return visiblePage
-    }
-
-    fun getReadAloudPos(): Pair<Int, TextLine>? {
-        var relativeOffset: Float
-        for (relativePos in 0..2) {
-            relativeOffset = relativeOffset(relativePos)
-            if (relativePos > 0) {
-                //滚动翻页
-                if (!requireCallBack.isScroll) break
-                if (relativeOffset >= ChapterProvider.visibleHeight) break
-            }
-            val textPage = relativePage(relativePos)
-            val lines = textPage.lines
-            for (i in lines.indices) {
-                val textLine = lines[i]
-                if (textLine.isVisible(relativeOffset)) {
-                    val visibleLine = textLine.copy().apply {
-                        lineTop += relativeOffset
-                        lineBottom += relativeOffset
-                    }
-                    return textPage.chapterIndex to visibleLine
-                }
-            }
-        }
-        return null
-    }
-
-    /**
-     * 选择开始文字
-     */
-    fun selectStartMoveIndex(
-        relativePagePos: Int,
-        lineIndex: Int,
-        charIndex: Int,
-    ) {
-        selectStart.relativePagePos = relativePagePos
-        selectStart.lineIndex = lineIndex
-        selectStart.columnIndex = max(0, charIndex)
-        val textLine = relativePage(relativePagePos).getLine(lineIndex)
-        val textColumn = textLine.getColumn(charIndex)
-        upSelectedStart(
-            if (charIndex < textLine.columns.size) textColumn.start else textColumn.end,
-            textLine.lineBottom + relativeOffset(relativePagePos),
-            textLine.lineTop + relativeOffset(relativePagePos)
-        )
-        upSelectChars()
-    }
-
-    fun selectStartMoveIndex(textPos: TextPos) = textPos.run {
-        selectStartMoveIndex(relativePagePos, lineIndex, columnIndex)
-    }
-
-    /**
-     * 选择结束文字
-     */
-    fun selectEndMoveIndex(
-        relativePage: Int,
-        lineIndex: Int,
-        charIndex: Int,
-    ) {
-        selectEnd.relativePagePos = relativePage
-        selectEnd.lineIndex = lineIndex
-        val textLine = relativePage(relativePage).getLine(lineIndex)
-        selectEnd.columnIndex = min(charIndex, textLine.columns.lastIndex)
-        val textColumn = textLine.getColumn(charIndex)
-        upSelectedEnd(
-            if (charIndex > -1) textColumn.end else textColumn.start,
-            textLine.lineBottom + relativeOffset(relativePage)
-        )
-        upSelectChars()
-    }
-
-    fun selectEndMoveIndex(textPos: TextPos) = textPos.run {
-        selectEndMoveIndex(relativePagePos, lineIndex, columnIndex)
-    }
-
-    private fun upSelectChars() {
-        if (!selectStart.isSelected() && !selectEnd.isSelected()) {
-            return
-        }
-        val last = if (requireCallBack.isScroll) 2 else 0
-        val textPos = TextPos(0, 0, 0)
-        for (relativePos in 0..last) {
-            textPos.relativePagePos = relativePos
-            val textPage = relativePage(relativePos)
-            for ((lineIndex, textLine) in textPage.lines.withIndex()) {
-                textPos.lineIndex = lineIndex
-                for ((charIndex, column) in textLine.columns.withIndex()) {
-                    textPos.columnIndex = charIndex
-                    if (column is TextBaseColumn) {
-                        val compareStart = textPos.compare(selectStart)
-                        val compareEnd = textPos.compare(selectEnd)
-                        column.selected = compareStart >= 0 && compareEnd <= 0
-                        column.isSearchResult =
-                            column.selected && requireCallBack.isSelectingSearchResult
-                        if (column.isSearchResult) {
-                            textPage.searchResult.add(column)
-                        }
-                    }
-                }
-            }
-        }
-        postInvalidate()
-    }
-
-    private fun upSelectedStart(x: Float, y: Float, top: Float) {
-        requireCallBack.run {
-            upSelectedStart(x + imgBgPaddingStart, y + headerHeight, top + headerHeight)
-        }
-    }
-
-    private fun upSelectedEnd(x: Float, y: Float) {
-        requireCallBack.run {
-            upSelectedEnd(x + imgBgPaddingStart, y + headerHeight)
-        }
-    }
-
-    fun resetReverseCursor() {
-        reverseStartCursor = false
-        reverseEndCursor = false
-    }
-
-    fun cancelSelect(clearSearchResult: Boolean = false) {
-        val last = if (requireCallBack.isScroll) 2 else 0
-        for (relativePos in 0..last) {
-            val textPage = relativePage(relativePos)
-            textPage.lines.forEach { textLine ->
-                textLine.columns.forEach {
-                    if (it is TextBaseColumn) {
-                        it.selected = false
-                        if (clearSearchResult) {
-                            it.isSearchResult = false
-                            textPage.searchResult.remove(it)
-                        }
-                    }
-                }
-            }
-        }
-        selectStart.reset()
-        selectEnd.reset()
-        postInvalidate()
-        requireCallBack.onCancelSelect()
-    }
-
-    fun getSelectedText(): String {
-        val textPos = TextPos(0, 0, 0)
-        val builder = StringBuilder()
-        for (relativePos in selectStart.relativePagePos..selectEnd.relativePagePos) {
-            val textPage = relativePage(relativePos)
-            textPos.relativePagePos = relativePos
-            textPage.lines.forEachIndexed { lineIndex, textLine ->
-                textPos.lineIndex = lineIndex
-                textLine.columns.forEachIndexed { charIndex, column ->
-                    textPos.columnIndex = charIndex
-                    val compareStart = textPos.compare(selectStart)
-                    val compareEnd = textPos.compare(selectEnd)
-                    if (column is TextBaseColumn) {
-                        when {
-                            compareStart == -1 -> if (
-                                selectStart.columnIndex == textLine.columns.size
-                                && charIndex == textLine.columns.lastIndex
-                            ) {
-                                builder.append("\n")
-                            }
-
-                            compareEnd == 1 -> if (selectEnd.columnIndex == -1 && charIndex == 0) {
-                                builder.append("\n")
-                            }
-
-                            compareStart >= 0 && compareEnd <= 0 -> {
-                                builder.append(column.charData)
-                                if (
-                                    textLine.isParagraphEnd
-                                    && charIndex == textLine.columns.lastIndex
-                                    && compareEnd != 0
-                                ) {
-                                    builder.append("\n")
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-        return builder.toString()
-    }
-
-    fun createBookmark(): Bookmark? {
-        val page = relativePage(selectStart.relativePagePos)
-        page.getTextChapter().let { chapter ->
-            ReadBook.book?.let { book ->
-                return book.createBookMark().apply {
-                    chapterIndex = page.chapterIndex
-                    chapterPos = chapter.getReadLength(page.index) +
-                            page.getPosByLineColumn(selectStart.lineIndex, selectStart.columnIndex)
-                    chapterName = chapter.title
-                    bookText = getSelectedText()
-                }
-            }
-        }
-        return null
-    }
-
-    private fun relativeOffset(relativePos: Int): Float {
-        return when (relativePos) {
-            0 -> pageOffset.toFloat()
-            1 -> pageOffset + textPage.height
-            else -> pageOffset + textPage.height + pageFactory.nextPage.height
-        }
-    }
-
-    fun relativePage(relativePos: Int): TextPage {
-        return when (relativePos) {
-            0 -> textPage
-            1 -> pageFactory.nextPage
-            else -> pageFactory.nextPlusPage
-        }
-    }
-
-    fun setAutoPager(autoPager: AutoPager?) {
-        this.autoPager = autoPager
-    }
-
-    fun setIsScroll(value: Boolean) {
-        isScroll = value
-    }
-
-    override fun canScrollVertically(direction: Int): Boolean {
-        return requireCallBack.isScroll && pageFactory.hasNext()
-    }
-
-    override fun dispatchTouchEvent(event: MotionEvent): Boolean {
-        when (event.action) {
-            MotionEvent.ACTION_DOWN -> {
-                longScreenshot = true
-                scrollY = 0
-            }
-
-            MotionEvent.ACTION_UP -> {
-                longScreenshot = false
-                scrollY = 0
-            }
-        }
-        return requireCallBack.onLongScreenshotTouchEvent(event)
-    }
-
-    companion object {
-        private val renderThread by lazy {
-            Executors.newSingleThreadExecutor {
-                Thread(it, "TextPageRender")
-            }
-        }
-        private val cursorWidth = 24.dpToPx()
-    }
-
-    interface CallBack {
-        val readerLayoutController: ReaderLayoutController
-        val headerHeight: Int
-        val imgBgPaddingStart: Int
-        val pageFactory: TextPageFactory
-        val pageDelegate: PageDelegate?
-        val isScroll: Boolean
-        var isSelectingSearchResult: Boolean
-        fun upSelectedStart(x: Float, y: Float, top: Float)
-        fun upSelectedEnd(x: Float, y: Float)
-        fun onImageLongPress(x: Float, y: Float, src: String)
-        fun onCancelSelect()
-        fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean
-        fun oldClickImg(src: String): Boolean
-        fun clickImg(click: String, src: String)
-        fun onMarkingClick(markingId: String)
-    }
-}

+ 0 - 19
app/src/main/java/io/legado/app/ui/book/read/page/CustomTipPageRemaining.kt

@@ -1,19 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-/**
- * 自定义页眉/页脚模板里 `{PageRemaining}` 的取值。
- *
- * **刻意只出数字,剩 0 页也不换成「本章完」**:模板是用户自己拼的,占位符两侧往往还有
- * 自己写的文案(「剩 {PageRemaining} 页」),中途把数字换成一句话会让整行读不通。
- * 要「本章完」这种语义,用户在模板里自己写即可。
- *
- * 唯一的非数字取值是 `-`:章节还没排完且页数未知,此时余量无从算起。
- */
-internal fun formatCustomTipPageRemaining(
-    pageSize: Int,
-    pageIndex: Int,
-    isChapterCompleted: Boolean,
-): String {
-    if (!isChapterCompleted && pageSize <= 0) return "-"
-    return (pageSize - pageIndex - 1).coerceAtLeast(0).toString()
-}

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

@@ -1,888 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.Canvas
-import android.graphics.Typeface
-import android.graphics.drawable.LayerDrawable
-import android.os.Build
-import android.view.LayoutInflater
-import android.view.WindowInsets
-import android.widget.FrameLayout
-import androidx.core.content.ContextCompat
-import androidx.core.graphics.drawable.toDrawable
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.isGone
-import androidx.core.view.updateLayoutParams
-import io.legado.app.R
-import io.legado.app.constant.AppConst.timeFormat
-import io.legado.app.constant.ReadTipType
-import io.legado.app.data.entities.Bookmark
-import io.legado.app.databinding.ViewBookPageBinding
-import io.legado.app.help.config.CustomTipPlaceholder
-import io.legado.app.model.ReadBook
-import io.legado.app.model.ReadSessionState
-import io.legado.app.model.ReaderBookmarkState
-import io.legado.app.ui.book.read.page.entities.TextLine
-import io.legado.app.ui.book.read.page.entities.TextPage
-import io.legado.app.ui.book.read.page.entities.TextPos
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.book.read.page.provider.TipStyleProvider
-import io.legado.app.ui.book.read.sheet.CustomTipTarget
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.ui.widget.BatteryView
-import io.legado.app.utils.activity
-import io.legado.app.utils.applyStatusBarPadding
-import io.legado.app.utils.canvasrecorder.CanvasRecorder
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.gone
-import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
-import io.legado.app.utils.statusBarHeight
-import splitties.views.backgroundColor
-import java.util.Date
-import io.legado.app.utils.screenshot as viewScreenshot
-
-/**
- * 页面视图
- */
-class PageView(
-    context: Context,
-    callBack: ContentTextView.CallBack? = null,
-) : FrameLayout(context) {
-
-    private val binding = ViewBookPageBinding.inflate(LayoutInflater.from(context), this, true)
-    private var battery = 100
-    private var tvTitle: BatteryView? = null
-    private var tvTime: BatteryView? = null
-    private var tvBattery: BatteryView? = null
-    private var tvBatteryP: BatteryView? = null
-    private var tvPage: BatteryView? = null
-    private var tvTotalProgress: BatteryView? = null
-    private var tvTotalProgress1: BatteryView? = null
-    private var tvPageAndTotal: BatteryView? = null
-    private var tvWholeBookPage: BatteryView? = null
-    private var tvWholeBookPageAndProgress: BatteryView? = null
-    private var tvBookName: BatteryView? = null
-    private var tvTimeBattery: BatteryView? = null
-    private var tvTimeBatteryP: BatteryView? = null
-    private var tvTitleArrow: BatteryView? = null
-    private var tvBatteryInside: BatteryView? = null
-    private var tvBatteryIcon: BatteryView? = null
-    private var tvBatteryClassic: BatteryView? = null
-    private var tvTimeBatteryClassic: BatteryView? = null
-    private var tvTitleArrowClassic: BatteryView? = null
-
-    private var isMainView = false
-    var isScroll = false
-
-    /** 已应用到角标 ImageView 的宽度 px,用于跳过 layout 重复更新的热路径。 */
-    private var appliedBadgeSizePx: Int = -1
-
-    private var currentTextPage: TextPage? = null
-
-    /**
-     * 当前页范围内是否落有书签。与角标同源([ReaderBookmarkState] 快照),
-     * 供角标显示与下滑手势的提示文案共用。
-     */
-    fun hasBookmarkOnCurrentPage(): Boolean {
-        val page = currentTextPage ?: return false
-        val book = ReadBook.book ?: return false
-        if (page.isMsgPage || page.lineSize <= 0) return false
-        return ReaderBookmarkState.hasBookmarkInRange(
-            bookName = book.name,
-            bookAuthor = book.author,
-            chapterIndex = page.chapterIndex,
-            startPos = page.chapterPosition,
-            endPos = page.chapterPosition + page.charSize,
-        )
-    }
-
-    /**
-     * 右上角书签角标:本页范围内落有书签时显示。
-     *
-     * 滚动模式一屏可见多页,`currentTextPage` 不再唯一对应可见内容,故该模式下不显示——
-     * 与下滑手势在滚动模式下同样不启用保持一致。
-     */
-    fun upBookmarkBadge() {
-        val visible = !isScroll && hasBookmarkOnCurrentPage()
-        val badge = binding.ivBookmarkBadge
-        if (!visible) {
-            badge.isGone = true
-            return
-        }
-        badge.isGone = false
-        // 角标为 1:2 的丝带,宽度由 bookmarkBadgeSize 决定;仅当尺寸变化才更新布局。
-        val sizeDp = ReadConfig.bookmarkBadgeSize.coerceAtLeast(1)
-        val sizePx = sizeDp.dpToPx()
-        if (appliedBadgeSizePx != sizePx) {
-            appliedBadgeSizePx = sizePx
-            badge.updateLayoutParams {
-                width = sizePx
-                height = sizePx * 2
-            }
-        }
-        // 默认是固定的黄色书签丝带(见 ic_bookmark_badge);用户可自定义为任意图片。
-        // 默认 drawable 每次 getDrawable 都是新实例,用 constantState 比对避免反复 setImageDrawable。
-        val custom = ReaderBookmarkBadge.drawable(context)
-        val target = custom ?: ContextCompat.getDrawable(context, R.drawable.ic_bookmark_badge)
-        if (badge.drawable !== target && badge.drawable?.constantState !== target?.constantState) {
-            badge.setImageDrawable(target)
-        }
-    }
-
-    val headerHeight: Int
-        get() {
-            val h1 = if (binding.vwStatusBar.isGone) 0 else binding.vwStatusBar.height
-            val h2 = if (binding.llHeader.isGone) 0 else binding.llHeader.height
-            return h1 + h2 + binding.vwRoot.paddingTop
-        }
-
-    val imgBgPaddingStart: Int
-        get() {
-            return binding.vwRoot.paddingStart
-        }
-
-    val contentOffsetX: Int get() = binding.contentTextView.left
-
-    val contentOffsetY: Int get() = binding.contentTextView.top
-
-    val contentWidth: Int get() = binding.contentTextView.width
-
-    val contentHeight: Int get() = binding.contentTextView.height
-
-    init {
-        callBack?.let { binding.contentTextView.setCallBack(it) }
-        upStyle()
-        binding.vwStatusBar.applyStatusBarPadding()
-        val initialNavigationBarHeight = currentNavigationBarHeight()
-        if (initialNavigationBarHeight > 0) {
-            binding.vwNavigationBar.updateLayoutParams {
-                height = initialNavigationBarHeight
-            }
-        }
-        binding.vwNavigationBar.setOnApplyWindowInsetsListenerCompat { v, windowInsets ->
-            val isImeVisible = windowInsets.isVisible(WindowInsetsCompat.Type.ime())
-            if (isImeVisible) {
-                return@setOnApplyWindowInsetsListenerCompat windowInsets
-            }
-            //Log.d("fansangg", "vwNavigationBar OnApplyWindowInsetsListener: navHeight=$navHeight, isImeVisible=${windowInsets.isVisible(WindowInsetsCompat.Type.ime())}, imeHeight=${windowInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom}, systemBarsHeight=${windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom}")
-            val navHeight = windowInsets.getInsetsIgnoringVisibility(
-                WindowInsetsCompat.Type.navigationBars()
-            ).bottom
-            if (navHeight > 0) {
-                ReadSessionState.lastNavigationBarHeight = navHeight
-            }
-            v.updateLayoutParams {
-                height = navHeight
-            }
-            windowInsets
-        }
-    }
-
-    private fun currentNavigationBarHeight(): Int {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
-            val stableHeight = activity?.windowManager?.currentWindowMetrics?.windowInsets
-                ?.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars())
-                ?.bottom
-                ?: 0
-            if (stableHeight > 0) {
-                ReadSessionState.lastNavigationBarHeight = stableHeight
-                return stableHeight
-            }
-        }
-        return ReadSessionState.lastNavigationBarHeight
-    }
-
-    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
-        super.onSizeChanged(w, h, oldw, oldh)
-        upBg()
-    }
-
-    fun upStyle() = binding.run {
-        val style = TipStyleProvider.style
-        upTipStyle(style)
-        applyTipColors(style)
-        upStatusBar()
-        upNavigationBar()
-        upPaddingDisplayCutouts()
-        llHeader.setPadding(
-            style.headerPaddingLeft.dpToPx(),
-            style.headerPaddingTop.dpToPx(),
-            style.headerPaddingRight.dpToPx(),
-            style.headerPaddingBottom.dpToPx()
-        )
-        llFooter.setPadding(
-            style.footerPaddingLeft.dpToPx(),
-            style.footerPaddingTop.dpToPx(),
-            style.footerPaddingRight.dpToPx(),
-            style.footerPaddingBottom.dpToPx()
-        )
-        vwTopDivider.gone(llHeader.isGone || !style.showHeaderLine)
-        vwBottomDivider.gone(llFooter.isGone || !style.showFooterLine)
-        upTime()
-        upBattery(battery)
-    }
-
-    /** 仅更新日夜模式相关颜色,避免主题切换时重新加载字体和重排正文。 */
-    fun upThemeColors() = binding.run {
-        applyTipColors(TipStyleProvider.style)
-        upBookmarkBadge()
-        contentTextView.invalidate()
-    }
-
-    /** 页眉/页脚文字色与分隔线色:0 表示跟随正文色,-1(分隔线)表示用主题色。 */
-    private fun applyTipColors(style: TipStyleProvider.TipStyle) = binding.run {
-        val textColor = style.textColor
-        val headerColor = if (style.tipHeaderColor == 0) textColor else style.tipHeaderColor
-        val footerColor = if (style.tipFooterColor == 0) textColor else style.tipFooterColor
-        val dividerColor = when (style.tipDividerColor) {
-            -1 -> ContextCompat.getColor(context, R.color.divider)
-            0 -> textColor
-            else -> style.tipDividerColor
-        }
-        tvHeaderLeft.setColor(headerColor)
-        tvHeaderMiddle.setColor(headerColor)
-        tvHeaderRight.setColor(headerColor)
-        tvFooterLeft.setColor(footerColor)
-        tvFooterMiddle.setColor(footerColor)
-        tvFooterRight.setColor(footerColor)
-        vwTopDivider.backgroundColor = dividerColor
-        vwBottomDivider.backgroundColor = dividerColor
-    }
-
-    /**
-     * 显示状态栏时隐藏header
-     */
-    fun upStatusBar() = with(binding.vwStatusBar) {
-        setPadding(paddingLeft, context.statusBarHeight, paddingRight, paddingBottom)
-        isGone = TipStyleProvider.style.hideStatusBar || activity?.isInMultiWindowMode == true
-    }
-
-    fun upNavigationBar() {
-        binding.vwNavigationBar.isGone = TipStyleProvider.style.hideNavigationBar
-    }
-
-    fun upPaddingDisplayCutouts() {
-        if (ReadConfig.paddingDisplayCutouts) {
-            binding.vwRoot.setOnApplyWindowInsetsListenerCompat { _, windowInsets ->
-                val insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
-                binding.vwRoot.setPadding(
-                    insets.left,
-                    if (binding.vwStatusBar.isGone) insets.top else 0,
-                    insets.right,
-                    insets.bottom
-                )
-                windowInsets
-            }
-        } else {
-            ViewCompat.setOnApplyWindowInsetsListener(binding.vwRoot, null)
-            binding.vwRoot.setPadding(0, 0, 0, 0)
-        }
-    }
-
-    /**
-     * 更新阅读信息
-     */
-    private fun upTipStyle(style: TipStyleProvider.TipStyle) = binding.run {
-        tvHeaderLeft.tag = null
-        tvHeaderMiddle.tag = null
-        tvHeaderRight.tag = null
-        tvFooterLeft.tag = null
-        tvFooterMiddle.tag = null
-        tvFooterRight.tag = null
-        llHeader.isGone = when (style.headerMode) {
-            1 -> false
-            2 -> true
-            else -> !style.hideStatusBar
-        }
-        llFooter.isGone = when (style.footerMode) {
-            1 -> true
-            else -> false
-        }
-        style.apply {
-            tvHeaderLeft.isGone = tipHeaderLeft == ReadTipType.tipNone
-            tvHeaderMiddle.isGone = tipHeaderMiddle == ReadTipType.tipNone
-            if (tipHeaderRight == ReadTipType.tipNone) {
-                if (tipHeaderMiddle == ReadTipType.tipNone && tipHeaderLeft == ReadTipType.tipNone) {
-                    tvHeaderRight.isGone = true
-                } else {
-                    tvHeaderRight.isGone = false
-                    tvHeaderRight.batteryMode = BatteryView.BatteryMode.EMPTY
-                }
-            } else {
-                tvHeaderRight.isGone = false
-                tvHeaderRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            }
-            tvFooterLeft.isGone = tipFooterLeft == ReadTipType.tipNone
-            tvFooterMiddle.isGone = tipFooterMiddle == ReadTipType.tipNone
-            if (tipFooterRight == ReadTipType.tipNone) {
-                if (tipFooterLeft == ReadTipType.tipNone && tipFooterMiddle == ReadTipType.tipNone) {
-                    tvFooterRight.isGone = true
-                } else {
-                    tvFooterRight.isGone = false
-                    tvFooterRight.batteryMode = BatteryView.BatteryMode.EMPTY
-                }
-            } else {
-                tvFooterRight.isGone = false
-                tvFooterRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            }
-        }
-        val tipTypeface = style.tipTypeface ?: ChapterProvider.typeface
-        val tipTextSize = style.tipTextSize
-        tvTitle = getTipView(ReadTipType.tipChapterTitle)?.apply {
-            tag = ReadTipType.tipChapterTitle
-            typeface = tipTypeface
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-        }
-        tvTitleArrow = getTipView(ReadTipType.tipChapterTitleArrow)?.apply {
-            tag = ReadTipType.tipChapterTitleArrow
-            typeface = Typeface.DEFAULT
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.ARROW
-        }
-        tvTitleArrowClassic = getTipView(ReadTipType.tipChapterTitleArrowClassic)?.apply {
-            tag = ReadTipType.tipChapterTitleArrowClassic
-            typeface = tipTypeface
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.ARROW
-        }
-        tvTime = getTipView(ReadTipType.tipTime)?.apply {
-            tag = ReadTipType.tipTime
-            typeface = tipTypeface
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-        }
-        tvBattery = getTipView(ReadTipType.tipBattery)?.apply {
-            tag = ReadTipType.tipBattery
-            typeface = Typeface.DEFAULT
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.OUTER
-        }
-        tvBatteryClassic = getTipView(ReadTipType.tipBatteryClassic)?.apply {
-            tag = ReadTipType.tipBatteryClassic
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.CLASSIC
-        }
-        tvBatteryInside = getTipView(ReadTipType.tipBatteryInside)?.apply {
-            tag = ReadTipType.tipBatteryInside
-            typeface = Typeface.DEFAULT
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.INNER
-        }
-        tvBatteryIcon = getTipView(ReadTipType.tipBatteryIcon)?.apply {
-            tag = ReadTipType.tipBatteryIcon
-            typeface = Typeface.DEFAULT
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.ICON
-        }
-        tvPage = getTipView(ReadTipType.tipPage)?.apply {
-            tag = ReadTipType.tipPage
-            typeface = tipTypeface
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-        }
-        tvTotalProgress = getTipView(ReadTipType.tipTotalProgress)?.apply {
-            tag = ReadTipType.tipTotalProgress
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = tipTypeface
-            textSize = tipTextSize
-        }
-        tvTotalProgress1 = getTipView(ReadTipType.tipTotalProgress1)?.apply {
-            tag = ReadTipType.tipTotalProgress1
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = tipTypeface
-            textSize = tipTextSize
-        }
-        tvPageAndTotal = getTipView(ReadTipType.tipPageAndTotal)?.apply {
-            tag = ReadTipType.tipPageAndTotal
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = tipTypeface
-            textSize = tipTextSize
-        }
-        tvWholeBookPage = getTipView(ReadTipType.tipWholeBookPage)?.apply {
-            tag = ReadTipType.tipWholeBookPage
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = tipTypeface
-            textSize = tipTextSize
-        }
-        tvWholeBookPageAndProgress =
-            getTipView(ReadTipType.tipWholeBookPageAndProgress)?.apply {
-                tag = ReadTipType.tipWholeBookPageAndProgress
-                batteryMode = BatteryView.BatteryMode.NO_BATTERY
-                typeface = tipTypeface
-                textSize = tipTextSize
-            }
-        tvBookName = getTipView(ReadTipType.tipBookName)?.apply {
-            tag = ReadTipType.tipBookName
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = tipTypeface
-            textSize = tipTextSize
-        }
-        tvTimeBattery = getTipView(ReadTipType.tipTimeBattery)?.apply {
-            tag = ReadTipType.tipTimeBattery
-            typeface = Typeface.DEFAULT
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.TIME
-        }
-        tvTimeBatteryClassic = getTipView(ReadTipType.tipTimeBatteryClassic)?.apply {
-            tag = ReadTipType.tipTimeBatteryClassic
-            typeface = tipTypeface
-            textSize = tipTextSize
-            batteryMode = BatteryView.BatteryMode.CLASSIC
-        }
-        tvBatteryP = getTipView(ReadTipType.tipBatteryPercentage)?.apply {
-            tag = ReadTipType.tipBatteryPercentage
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = tipTypeface
-            textSize = tipTextSize
-        }
-        tvTimeBatteryP = getTipView(ReadTipType.tipTimeBatteryPercentage)?.apply {
-            tag = ReadTipType.tipTimeBatteryPercentage
-            batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = tipTypeface
-            textSize = tipTextSize
-        }
-        // 自定义页眉/页脚模板:6 个位置共享同一段配置逻辑
-        val customTypeface = tipTypeface
-        val customTextSize = tipTextSize
-        for (target in CUSTOM_TIP_TARGETS) {
-            if (target.currentTipValue() != ReadTipType.tipCustom) continue
-            val view = customTipViewFor(target)
-            view.tag = ReadTipType.tipCustom
-            view.typeface = customTypeface
-            view.textSize = customTextSize
-            view.batteryMode = BatteryView.BatteryMode.NO_BATTERY
-        }
-
-        // 当页脚不应用页眉字体样式时,覆盖页脚视图的字体和字号
-        if (!style.applyHeaderStyle) {
-            val footerTypeface = style.footerTypeface ?: tipTypeface
-            val footerTextSize = style.footerTextSize
-            listOf(tvFooterLeft, tvFooterMiddle, tvFooterRight).forEach { view ->
-                if (view.tag != null) {
-                    view.typeface = footerTypeface
-                    view.textSize = footerTextSize
-                }
-            }
-        }
-    }
-
-    /**
-     * 根据 [target] 取得对应的页眉/页脚 [BatteryView] 实例。
-     */
-    private fun customTipViewFor(target: CustomTipTarget): BatteryView = binding.run {
-        when (target) {
-            CustomTipTarget.HEADER_LEFT -> tvHeaderLeft
-            CustomTipTarget.HEADER_MIDDLE -> tvHeaderMiddle
-            CustomTipTarget.HEADER_RIGHT -> tvHeaderRight
-            CustomTipTarget.FOOTER_LEFT -> tvFooterLeft
-            CustomTipTarget.FOOTER_MIDDLE -> tvFooterMiddle
-            CustomTipTarget.FOOTER_RIGHT -> tvFooterRight
-        }
-    }
-
-    /**
-     * 获取信息视图
-     * @param tip 信息类型
-     */
-    private fun getTipView(tip: Int): BatteryView? = binding.run {
-        val style = TipStyleProvider.style
-        return when (tip) {
-            style.tipHeaderLeft -> tvHeaderLeft
-            style.tipHeaderMiddle -> tvHeaderMiddle
-            style.tipHeaderRight -> tvHeaderRight
-            style.tipFooterLeft -> tvFooterLeft
-            style.tipFooterMiddle -> tvFooterMiddle
-            style.tipFooterRight -> tvFooterRight
-            else -> null
-        }
-    }
-
-    /**
-     * 更新背景
-     */
-    fun upBg() {
-        binding.vwRoot.background = LayerDrawable(
-            arrayOf(
-                ReadSessionState.backgroundMeanColor.toDrawable(),
-                ReadSessionState.background
-            )
-        )
-        upBgAlpha()
-    }
-
-    /**
-     * 更新背景透明度
-     */
-    fun upBgAlpha() {
-        ReadSessionState.background?.alpha = (TipStyleProvider.style.bgAlpha / 100f * 255).toInt()
-        binding.vwRoot.invalidate()
-    }
-
-    /**
-     * 更新时间信息
-     */
-    fun upTime() {
-        tvTime?.text = timeFormat.format(Date(System.currentTimeMillis()))
-        upTimeBattery()
-        upCustomTip(currentTextPage)
-    }
-
-    /**
-     * 更新电池信息
-     */
-    @SuppressLint("SetTextI18n")
-    fun upBattery(battery: Int) {
-        this.battery = battery
-        tvBattery?.setBattery(battery)
-        tvBatteryClassic?.setBattery(battery)
-        tvBatteryInside?.setBattery(battery)
-        tvBatteryIcon?.setBattery(battery)
-        tvBatteryP?.text = "$battery%"
-        upTimeBattery()
-        upCustomTip(currentTextPage)
-    }
-
-    /**
-     * 更新电池信息
-     */
-    @SuppressLint("SetTextI18n")
-    private fun upTimeBattery() {
-        val time = timeFormat.format(Date(System.currentTimeMillis()))
-        tvTimeBattery?.setBattery(battery, time)
-        tvTimeBatteryP?.setBattery(battery, time)
-        tvTimeBatteryClassic?.setBattery(battery, time)
-        tvTimeBatteryClassic?.text = "$time $battery%"
-        tvTimeBatteryP?.text = "$time $battery%"
-    }
-
-    /**
-     * 将所有启用自定义模板的页眉/页脚视图按当前 textPage 与最新时间/电池进行渲染。
-     * 当位置未启用 tipCustom 或模板为空时不做任何处理。
-     */
-    fun upCustomTip(textPage: TextPage?) {
-        val page = textPage ?: return
-        for (target in CUSTOM_TIP_TARGETS) {
-            if (target.currentTipValue() != ReadTipType.tipCustom) continue
-            val template = target.currentCustomTemplate()
-            val view = customTipViewFor(target)
-            if (template.isEmpty()) {
-                view.setTextIfNotEqual("")
-            } else {
-                view.setTextIfNotEqual(resolveCustomTemplate(template, page))
-            }
-        }
-    }
-
-    /**
-     * 将 [template] 中的预定义占位符替换为当前页面、时间、电池等实际值。
-     *
-     * 仅会替换 [CustomTipPlaceholder] 中枚举的合法 token;未识别的 `{Xxx}` 段将被原样保留
-     * (校验由 [CustomTipDialog] / [CustomTipPlaceholder.isValid] 在保存前完成)。
-     */
-    @SuppressLint("SetTextI18n")
-    private fun resolveCustomTemplate(template: String, textPage: TextPage): String {
-        if (template.isEmpty()) return ""
-        val time = timeFormat.format(Date(System.currentTimeMillis()))
-        val bookName = ReadBook.book?.name.orEmpty()
-        val chapterTitle = textPage.title
-        val chapterIndexDisplay = textPage.chapterIndex.plus(1).toString()
-        val chapterSizeDisplay = textPage.chapterSize.toString()
-        val pageIndexDisplay = textPage.index.plus(1).toString()
-        val pageSizeDisplay = if (textPage.textChapter.isCompleted) {
-            textPage.pageSize.toString()
-        } else {
-            val pageSizeInt = textPage.pageSize
-            if (pageSizeInt <= 0) "-" else pageSizeInt.toString()
-        }
-        val pageRemainingDisplay = formatCustomTipPageRemaining(
-            pageSize = textPage.pageSize,
-            pageIndex = textPage.index,
-            isChapterCompleted = textPage.textChapter.isCompleted,
-        )
-        val readProgressDisplay = textPage.readProgress
-        val wholeBookPage = ReadBook.getWholeBookPageState(textPage.chapterIndex, textPage.index)
-        val fullPageIndexDisplay = wholeBookPage?.currentPage?.toString() ?: pageIndexDisplay
-        val fullPageSizeDisplay = wholeBookPage?.totalPages?.toString() ?: pageSizeDisplay
-        // 使用本 PageView 实例持有的电池字段,值由 [upBattery] 在系统电量变化时持续刷新。
-        val batteryPercentDisplay = "$battery%"
-        var result = template
-        for (placeholder in CUSTOM_TIP_PLACEHOLDERS) {
-            val replacement = when (placeholder) {
-                CustomTipPlaceholder.BOOK_NAME -> bookName
-                CustomTipPlaceholder.CHAPTER_TITLE -> chapterTitle
-                CustomTipPlaceholder.TIME -> time
-                CustomTipPlaceholder.BATTERY_PERCENT -> batteryPercentDisplay
-                CustomTipPlaceholder.CHAPTER_INDEX -> chapterIndexDisplay
-                CustomTipPlaceholder.CHAPTER_SIZE -> chapterSizeDisplay
-                CustomTipPlaceholder.PAGE_INDEX -> pageIndexDisplay
-                CustomTipPlaceholder.PAGE_SIZE -> pageSizeDisplay
-                CustomTipPlaceholder.PAGE_REMAINING -> pageRemainingDisplay
-                CustomTipPlaceholder.READ_PROGRESS -> readProgressDisplay
-                CustomTipPlaceholder.FULL_PAGE_INDEX -> fullPageIndexDisplay
-                CustomTipPlaceholder.FULL_PAGE_SIZE -> fullPageSizeDisplay
-            }
-            result = result.replace(placeholder.token, replacement)
-        }
-        return result
-    }
-
-    /**
-     * 设置内容
-     */
-    fun setContent(textPage: TextPage, resetPageOffset: Boolean = true) {
-        if (isMainView && !isScroll) {
-            setProgress(textPage)
-        } else {
-            post {
-                setProgress(textPage)
-            }
-        }
-        if (resetPageOffset) {
-            resetPageOffset()
-        }
-        binding.contentTextView.setContent(textPage)
-    }
-
-    fun invalidateContentView() {
-        binding.contentTextView.invalidate()
-    }
-
-    /**
-     * 重置滚动位置
-     */
-    fun resetPageOffset() {
-        binding.contentTextView.resetPageOffset()
-    }
-
-    /**
-     * 设置进度
-     */
-    @SuppressLint("SetTextI18n")
-    fun setProgress(textPage: TextPage) = textPage.apply {
-        currentTextPage = textPage
-        tvBookName?.setTextIfNotEqual(ReadBook.book?.name)
-        tvTitle?.setTextIfNotEqual(textPage.title)
-        tvTitleArrow?.setTextIfNotEqual(textPage.title)
-        tvTitleArrowClassic?.setTextIfNotEqual(textPage.title)
-        val readProgress = readProgress
-        val wholeBookPage = ReadBook.getWholeBookPageState(chapterIndex, index)
-        val pageDisplay = wholeBookPage?.let {
-            if (!it.estimated && it.allPreviousChaptersExact) {
-                context.getString(R.string.whole_book_page_format, it.currentPage, it.totalPages)
-            } else {
-                context.getString(
-                    R.string.whole_book_page_estimated_format,
-                    it.currentPage.toString(),
-                    it.totalPages.toString(),
-                )
-            }
-        }
-        tvTotalProgress?.setTextIfNotEqual(readProgress)
-        tvTotalProgress1?.setTextIfNotEqual("${chapterIndex.plus(1)}/${chapterSize}")
-        if (textChapter.isCompleted) {
-            tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
-            tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
-        } else {
-            val pageSizeInt = pageSize
-            val pageSize = if (pageSizeInt <= 0) "-" else pageSizeInt.toString()
-            tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
-            tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
-        }
-        val wholeBookPageDisplay = pageDisplay
-            ?: context.getString(R.string.whole_book_page_unavailable)
-        tvWholeBookPage?.setTextIfNotEqual(wholeBookPageDisplay)
-        tvWholeBookPageAndProgress?.setTextIfNotEqual(
-            "$wholeBookPageDisplay  $readProgress"
-        )
-        upCustomTip(textPage)
-        upBookmarkBadge()
-        this@PageView.layoutSync()
-    }
-
-    fun layoutSync() {
-        if (width <= 0 || height <= 0) return
-        if (!isLayoutRequested) return
-        measure(
-            MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
-            MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
-        )
-        layout(left, top, right, bottom)
-    }
-
-    fun screenshot(canvasRecorder: CanvasRecorder) {
-        if (!isMainView && !isScroll) {
-            setProgress(textPage)
-        }
-        viewScreenshot(canvasRecorder)
-    }
-
-    fun screenshot(bitmap: Bitmap? = null, canvas: Canvas? = null): Bitmap? {
-        if (!isMainView && !isScroll) {
-            setProgress(textPage)
-        }
-        return viewScreenshot(bitmap, canvas)
-    }
-
-    fun setAutoPager(autoPager: AutoPager?) {
-        binding.contentTextView.setAutoPager(autoPager)
-    }
-
-    fun submitRenderTask() {
-        binding.contentTextView.submitRenderTask()
-    }
-
-    fun setIsScroll(value: Boolean) {
-        isScroll = value
-        binding.contentTextView.setIsScroll(value)
-        upBookmarkBadge()
-    }
-
-    /**
-     * 滚动事件
-     */
-    fun scroll(offset: Int) {
-        binding.contentTextView.scroll(offset)
-    }
-
-    /**
-     * 更新是否开启选择功能
-     */
-    fun upSelectAble(selectAble: Boolean) {
-        binding.contentTextView.selectAble = selectAble
-    }
-
-    /**
-     * 优先处理页面内单击
-     * @return true:已处理, false:未处理
-     */
-    fun onClick(x: Float, y: Float): Boolean {
-        return binding.contentTextView.click(x, y - headerHeight)
-    }
-
-    /**
-     * 长按事件
-     */
-    fun longPress(
-        x: Float, y: Float,
-        select: (textPos: TextPos) -> Unit,
-    ) {
-        return binding.contentTextView.longPress(x, y - headerHeight, select)
-    }
-
-    /**
-     * 选择文本
-     */
-    fun selectText(
-        x: Float, y: Float,
-        select: (textPos: TextPos) -> Unit,
-    ) {
-        return binding.contentTextView.selectText(x, y - headerHeight, select)
-    }
-
-    fun getCurVisiblePage(): TextPage {
-        return binding.contentTextView.getCurVisiblePage()
-    }
-
-    fun getReadAloudPos(): Pair<Int, TextLine>? {
-        return binding.contentTextView.getReadAloudPos()
-    }
-
-    fun markAsMainView() {
-        isMainView = true
-        binding.contentTextView.isMainView = true
-    }
-
-    fun selectStartMove(x: Float, y: Float) {
-        binding.contentTextView.selectStartMove(x, y - headerHeight)
-    }
-
-    fun selectStartMoveIndex(
-        relativePagePos: Int,
-        lineIndex: Int,
-        charIndex: Int
-    ) {
-        binding.contentTextView.selectStartMoveIndex(relativePagePos, lineIndex, charIndex)
-    }
-
-    fun selectStartMoveIndex(textPos: TextPos) {
-        binding.contentTextView.selectStartMoveIndex(textPos)
-    }
-
-    fun selectEndMove(x: Float, y: Float) {
-        binding.contentTextView.selectEndMove(x, y - headerHeight)
-    }
-
-    fun selectEndMoveIndex(
-        relativePagePos: Int,
-        lineIndex: Int,
-        charIndex: Int
-    ) {
-        binding.contentTextView.selectEndMoveIndex(relativePagePos, lineIndex, charIndex)
-    }
-
-    fun selectEndMoveIndex(textPos: TextPos) {
-        binding.contentTextView.selectEndMoveIndex(textPos)
-    }
-
-    fun getReverseStartCursor(): Boolean {
-        return binding.contentTextView.reverseStartCursor
-    }
-
-    fun getReverseEndCursor(): Boolean {
-        return binding.contentTextView.reverseEndCursor
-    }
-
-    fun isLongScreenShot(): Boolean {
-        return binding.contentTextView.longScreenshot
-    }
-
-    fun resetReverseCursor() {
-        binding.contentTextView.resetReverseCursor()
-    }
-
-    fun cancelSelect(clearSearchResult: Boolean = false) {
-        binding.contentTextView.cancelSelect(clearSearchResult)
-    }
-
-    fun createBookmark(): Bookmark? {
-        return binding.contentTextView.createBookmark()
-    }
-
-    fun relativePage(relativePagePos: Int): TextPage {
-        return binding.contentTextView.relativePage(relativePagePos)
-    }
-
-    val textPage get() = binding.contentTextView.textPage
-
-    val selectedText: String get() = binding.contentTextView.getSelectedText()
-
-    val selectStartPos get() = binding.contentTextView.selectStart
-
-    val selectEndPos get() = binding.contentTextView.selectEndPosition
-
-    companion object {
-        /**
-         * 缓存枚举数组,避免每次调用 `values()` 触发 Kotlin 内部数组克隆。
-         * 模板渲染在 `upCustomTip` / `upTime` / `upBattery` 等热路径上会反复触发。
-         */
-        private val CUSTOM_TIP_TARGETS: Array<CustomTipTarget> = CustomTipTarget.values()
-        private val CUSTOM_TIP_PLACEHOLDERS: Array<CustomTipPlaceholder> = CustomTipPlaceholder.values()
-    }
-}
-
-/**
- * 渲染层按位置取当前 tip 类型 / 自定义模板,读的是 [TipStyleProvider] 快照。
- *
- * 这两个函数在 `upCustomTip` / `upTime` / `upBattery` 热路径上反复触发,过去每次都直读
- * 可变全局 `ReadBookConfig`。设置面读的是 `ReadSheetConfigUiState`
- * (`CustomTipTarget.tipValueOf` / `customTemplateOf`),两侧互不影响。
- */
-private fun CustomTipTarget.currentTipValue(): Int =
-    TipStyleProvider.style.tipValueOf(this)
-
-private fun CustomTipTarget.currentCustomTemplate(): String =
-    TipStyleProvider.style.customTemplateOf(this)

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

@@ -1,1103 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.RectF
-import android.graphics.drawable.GradientDrawable
-import android.graphics.drawable.LayerDrawable
-import android.os.Build
-import android.os.Bundle
-import android.util.AttributeSet
-import android.util.Log
-import android.view.Gravity
-import android.view.MotionEvent
-import android.view.View
-import android.view.ViewConfiguration
-import android.view.WindowInsets
-import android.view.accessibility.AccessibilityEvent
-import android.view.accessibility.AccessibilityNodeInfo
-import android.view.animation.DecelerateInterpolator
-import android.widget.FrameLayout
-import android.widget.TextView
-import androidx.core.graphics.drawable.toDrawable
-import androidx.core.view.ViewCompat
-import androidx.core.view.updateLayoutParams
-import io.legado.app.BuildConfig
-import io.legado.app.R
-import io.legado.app.constant.PageAnim
-import io.legado.app.model.ReadSessionState
-import io.legado.app.ui.book.read.page.api.DataSource
-import io.legado.app.ui.book.read.page.delegate.CoverPageDelegate
-import io.legado.app.ui.book.read.page.delegate.FadePageDelegate
-import io.legado.app.ui.book.read.page.delegate.HorizontalPageDelegate
-import io.legado.app.ui.book.read.page.delegate.NoAnimPageDelegate
-import io.legado.app.ui.book.read.page.delegate.PageDelegate
-import io.legado.app.ui.book.read.page.delegate.ScrollPageDelegate
-import io.legado.app.ui.book.read.page.delegate.SimulationPageDelegate
-import io.legado.app.ui.book.read.page.delegate.SlidePageDelegate
-import io.legado.app.ui.book.read.page.entities.PageDirection
-import io.legado.app.ui.book.read.page.entities.TextChapter
-import io.legado.app.ui.book.read.page.entities.TextLine
-import io.legado.app.ui.book.read.page.entities.TextPage
-import io.legado.app.ui.book.read.page.entities.TextPos
-import io.legado.app.ui.book.read.page.entities.column.TextBaseColumn
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
-import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
-import io.legado.app.ui.book.read.page.provider.TextPageFactory
-import io.legado.app.ui.book.read.page.provider.TipStyleProvider
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.utils.activity
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.invisible
-import io.legado.app.utils.throttle
-import java.text.BreakIterator
-import java.util.Locale
-import kotlin.math.abs
-
-/**
- * 阅读视图
- */
-class ReadView(
-    context: Context,
-    attrs: AttributeSet? = null,
-    private val callBack: CallBack,
-    contentCallBack: ContentTextView.CallBack? = null,
-    private val eventListener: ReaderEventListener,
-    private val pageSource: ReaderPageSource,
-) :
-    FrameLayout(context, attrs),
-    DataSource, LayoutProgressListener {
-
-    var pageFactory: TextPageFactory = TextPageFactory(this, pageSource)
-    var pageDelegate: PageDelegate? = null
-        private set(value) {
-            field?.onDestroy()
-            field = null
-            field = value
-            upContent()
-        }
-    override var isScroll = false
-    val prevPage by lazy { PageView(context, contentCallBack) }
-    val curPage by lazy { PageView(context, contentCallBack) }
-    val nextPage by lazy { PageView(context, contentCallBack) }
-    val defaultAnimationSpeed = 300
-    private var pressDown = false
-    private var isMove = false
-
-    //起始点
-    var startX: Float = 0f
-    var startY: Float = 0f
-
-    //上一个触碰点
-    var lastX: Float = 0f
-    var lastY: Float = 0f
-
-    //触碰点
-    var touchX: Float = 0f
-    var touchY: Float = 0f
-
-    //是否停止动画动作
-    var isAbortAnim = false
-
-    //长按
-    private var longPressed = false
-    private val longPressTimeout = 600L
-    private val longPressRunnable = Runnable {
-        longPressed = true
-        onLongPress()
-    }
-    var isTextSelected = false
-    private var pressOnTextSelected = false
-    private val initialTextPos = TextPos(0, 0, 0)
-
-    /**
-     * 本次手势**仍可能**是「下滑切换书签」。
-     *
-     * 只是候选而非认领:每帧 MOVE 都按当前位移重新判定,因此中途转为横向占优的斜划会
-     * 交还 [pageDelegate] 正常翻页,不会被吞掉。候选期间不喂 [pageDelegate],
-     * 否则横向委托会同时启动翻页动画。
-     */
-    private var isBookmarkSwipe = false
-
-    /**
-     * 手势已交还 [pageDelegate],本次触摸不再收回。
-     *
-     * 交还那一刻委托就可能起了翻页动画,中途再抢回来会留下一个停在半路的动画。
-     */
-    private var bookmarkSwipeReleased = false
-    private val bookmarkSwipeMinDistance by lazy { BOOKMARK_SWIPE_MIN_DISTANCE_DP.dpToPx() }
-
-    /**
-     * 本次触摸是否启用下滑书签手势。在 DOWN 时读一次开关(DataStore 快照重建较贵,
-     * 不宜放进逐帧的 [isBookmarkSwipeCandidate]),改设置在下次触摸生效。
-     */
-    private var bookmarkSwipeEnabled = false
-
-    /**
-     * 顶部「松手加入书签」提示条:下滑距离达标后出现,松手即执行切换。
-     */
-    private val bookmarkSwipeHint = TextView(context).apply {
-        text = context.getString(R.string.bookmark_swipe_release_to_add)
-        gravity = Gravity.CENTER
-        setTextColor(Color.WHITE)
-        textSize = 14f
-        includeFontPadding = false
-        importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
-        background = GradientDrawable().apply {
-            cornerRadius = BOOKMARK_SWIPE_HINT_CORNER_RADIUS_DP.dpToPx()
-            setColor(Color.argb(160, 0, 0, 0))
-        }
-        setPadding(16.dpToPx(), 8.dpToPx(), 16.dpToPx(), 8.dpToPx())
-        layoutParams = FrameLayout.LayoutParams(
-            FrameLayout.LayoutParams.WRAP_CONTENT,
-            FrameLayout.LayoutParams.WRAP_CONTENT,
-            Gravity.TOP or Gravity.CENTER_HORIZONTAL,
-        ).apply {
-            topMargin = BOOKMARK_SWIPE_HINT_CONTENT_TOP_MARGIN_DP.dpToPx()
-        }
-        visibility = View.GONE
-    }
-
-    private val slopSquare by lazy { ViewConfiguration.get(context).scaledTouchSlop }
-    private var pageSlopSquare: Int = slopSquare
-    var pageSlopSquare2: Int = pageSlopSquare * pageSlopSquare
-    private val tlRect = RectF()
-    private val tcRect = RectF()
-    private val trRect = RectF()
-    private val mlRect = RectF()
-    private val mcRect = RectF()
-    private val mrRect = RectF()
-    private val blRect = RectF()
-    private val bcRect = RectF()
-    private val brRect = RectF()
-    private val boundary by lazy { BreakIterator.getWordInstance(Locale.getDefault()) }
-    private val upProgressThrottle = throttle(200) { post { upProgress() } }
-    val autoPager = AutoPager(this)
-    val isAutoPage get() = autoPager.isRunning
-    private var accessibilityPageText = ""
-
-    init {
-        if (!isInEditMode) {
-            // 重开阅读器的唯一快照重建入口:ReadConfigUpdateBus 无 replay,上一会话关闭后改的
-            // 配置(如设置页的「隐藏状态栏」)没有任何 effect 会补发;PageView 构造期就要读
-            // TipStyleProvider.style,所以必须在 addView 之前重建。两份快照纯派生、幂等。
-            ChapterProvider.upRenderStyle()
-            TipStyleProvider.upTipStyle()
-        }
-        addView(nextPage)
-        addView(curPage)
-        addView(prevPage)
-        addView(bookmarkSwipeHint)
-        // 三个 PageView 只负责把同一页绘制到 Canvas;它们的内部 View 没有正文语义。
-        // 读屏应只命中下面由 ReadView 提供的当前页文本节点。
-        listOf(prevPage, curPage, nextPage).forEach {
-            it.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
-        }
-        descendantFocusability = FOCUS_BLOCK_DESCENDANTS
-        importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
-        isClickable = true
-        isFocusable = true
-        ViewCompat.setScreenReaderFocusable(this, true)
-        prevPage.invisible()
-        nextPage.invisible()
-        curPage.markAsMainView()
-        if (!isInEditMode) {
-            upBg()
-            setWillNotDraw(false)
-            upPageAnim()
-            upPageSlopSquare()
-        }
-    }
-
-    private fun setRect9x() {
-        tlRect.set(0f, 0f, width * 0.33f, height * 0.33f)
-        tcRect.set(width * 0.33f, 0f, width * 0.66f, height * 0.33f)
-        trRect.set(width * 0.36f, 0f, width.toFloat(), height * 0.33f)
-        mlRect.set(0f, height * 0.33f, width * 0.33f, height * 0.66f)
-        mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
-        mrRect.set(width * 0.66f, height * 0.33f, width.toFloat(), height * 0.66f)
-        blRect.set(0f, height * 0.66f, width * 0.33f, height.toFloat())
-        bcRect.set(width * 0.33f, height * 0.66f, width * 0.66f, height.toFloat())
-        brRect.set(width * 0.66f, height * 0.66f, width.toFloat(), height.toFloat())
-    }
-
-    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
-        super.onSizeChanged(w, h, oldw, oldh)
-        setRect9x()
-        prevPage.x = -w.toFloat()
-        pageDelegate?.setViewSize(w, h)
-        if (w > 0 && h > 0) {
-            upBg()
-            callBack.upSystemUiVisibility()
-        }
-    }
-
-    override fun dispatchDraw(canvas: Canvas) {
-        super.dispatchDraw(canvas)
-        pageDelegate?.onDraw(canvas)
-        autoPager.onDraw(canvas)
-    }
-
-    override fun computeScroll() {
-        pageDelegate?.computeScroll()
-        autoPager.computeOffset()
-    }
-
-    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
-        return true
-    }
-
-    // Canvas 正文没有子级语义,触摸探索必须命中本页级节点。
-    override fun onInterceptHoverEvent(event: MotionEvent): Boolean = true
-
-    /**
-     * 触摸事件
-     */
-    override fun onTouchEvent(event: MotionEvent): Boolean {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
-            val insets = this.rootWindowInsets.getInsetsIgnoringVisibility(
-                WindowInsets.Type.mandatorySystemGestures()
-            )
-            val height = activity?.windowManager?.currentWindowMetrics?.bounds?.height()
-            if (height != null) {
-                if (event.y > height.minus(insets.bottom)
-                    && event.action != MotionEvent.ACTION_UP
-                    && event.action != MotionEvent.ACTION_CANCEL
-                ) {
-                    return true
-                }
-            }
-        }
-
-        //在多点触控时,事件不走ACTION_DOWN分支而产生的特殊事件处理
-        if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_UP) {
-            pageDelegate?.onTouch(event)
-        }
-        when (event.action) {
-            MotionEvent.ACTION_DOWN -> {
-                callBack.screenOffTimerStart()
-                if (isTextSelected) {
-                    curPage.cancelSelect()
-                    isTextSelected = false
-                    pressOnTextSelected = true
-                } else {
-                    pressOnTextSelected = false
-                }
-                longPressed = false
-                postDelayed(longPressRunnable, longPressTimeout)
-                pressDown = true
-                isMove = false
-                bookmarkSwipeEnabled = ReadConfig.swipeToAddBookmark
-                // 新触摸先清零:取消上一手势遗留的弹回动画并把页面归位。若不取消,
-                // 动画会与 MOVE 的逐帧 translationY 互相抢占,起手一段明显顿挫。
-                resetBookmarkSwipe(animate = false)
-                pageDelegate?.onTouch(event)
-                pageDelegate?.onDown()
-                setStartPoint(event.x, event.y, false)
-            }
-
-            MotionEvent.ACTION_MOVE -> {
-                if (!pressDown) return true
-                val absX = abs(startX - event.x)
-                val absY = abs(startY - event.y)
-                if (!isMove) {
-                    isMove = absX > slopSquare || absY > slopSquare
-                }
-                if (isMove) {
-                    longPressed = false
-                    removeCallbacks(longPressRunnable)
-                    // 每帧按当前累计位移重新判定,方向翻转时才能交还 pageDelegate。
-                    if (!bookmarkSwipeReleased) {
-                        isBookmarkSwipe = isBookmarkSwipeCandidate(event.y - startY, absX, absY)
-                        if (!isBookmarkSwipe) {
-                            bookmarkSwipeReleased = true
-                            // 交还 pageDelegate 前把页面拉回原位,避免委托拿到被移位的 curPage。
-                            endBookmarkSwipeDrag()
-                        } else {
-                            // 页面随手指下移;到位后顶部亮起「松手加入书签」。
-                            updateBookmarkSwipeDrag(event.y - startY)
-                        }
-                    }
-                    if (isTextSelected) {
-                        selectText(event.x, event.y)
-                    } else if (!isBookmarkSwipe) {
-                        pageDelegate?.onTouch(event)
-                    }
-                }
-            }
-
-            MotionEvent.ACTION_UP -> {
-                callBack.screenOffTimerStart()
-                removeCallbacks(longPressRunnable)
-                if (!pressDown) return true
-                pressDown = false
-                if (pageDelegate?.isMoved == false && !isMove) {
-                    if (!longPressed && !pressOnTextSelected) {
-                        performClick()
-                        val handled = curPage.onClick(startX, startY)
-                        if (!handled) {
-                            onSingleTapUp()
-                        }
-                        return true
-                    }
-                }
-                if (isBookmarkSwipe) {
-                    // 以抬手时的最终位移复核:MOVE 之后手指还可能拐弯或回抽。
-                    val dy = event.y - startY
-                    if (dy >= bookmarkSwipeMinDistance &&
-                        isBookmarkSwipeCandidate(dy, abs(startX - event.x), abs(dy))
-                    ) {
-                        eventListener.onEvent(ReaderEvent.ToggleBookmark)
-                    }
-                    resetBookmarkSwipe()
-                } else if (isTextSelected) {
-                    callBack.showTextActionMenu()
-                } else if (pageDelegate!!.isMoved) {
-                    pageDelegate?.onTouch(event)
-                }
-                pressOnTextSelected = false
-            }
-
-            MotionEvent.ACTION_CANCEL -> {
-                removeCallbacks(longPressRunnable)
-                if (!pressDown) return true
-                pressDown = false
-                resetBookmarkSwipe()
-                if (isTextSelected) {
-                    callBack.showTextActionMenu()
-                } else if (pageDelegate!!.isMoved) {
-                    pageDelegate?.onTouch(event)
-                }
-                pressOnTextSelected = false
-                autoPager.resume()
-            }
-        }
-        return true
-    }
-
-    /**
-     * 当前位移是否仍算「下滑切换书签」:向下、且竖直位移压过水平位移。
-     *
-     * @param dy 相对按下点的竖直位移,向下为正
-     */
-    private fun isBookmarkSwipeCandidate(dy: Float, absX: Float, absY: Float): Boolean =
-        bookmarkSwipeEnabled && !isScroll && !isTextSelected &&
-            dy > 0 && absY > absX * VERTICAL_DOMINANCE_RATIO
-
-    /**
-     * 手势结束(抬手/取消):页面弹回原位、收起提示条。翻页委托不受影响。
-     */
-    private fun resetBookmarkSwipe(animate: Boolean = true) {
-        isBookmarkSwipe = false
-        bookmarkSwipeReleased = false
-        hideBookmarkSwipeHint()
-        curPage.animate().cancel()
-        if (animate && curPage.translationY != 0f) {
-            curPage.animate()
-                .translationY(0f)
-                .setDuration(BOOKMARK_SWIPE_SPRING_BACK_MS)
-                .setInterpolator(DecelerateInterpolator())
-                .start()
-        } else {
-            curPage.translationY = 0f
-        }
-    }
-
-    /**
-     * 手势已交还 [pageDelegate](中途转为横向翻页):立即把页面拉回原位,
-     * 不起动画,避免与委托的翻页动画互相干扰。
-     */
-    private fun endBookmarkSwipeDrag() {
-        hideBookmarkSwipeHint()
-        curPage.animate().cancel()
-        curPage.translationY = 0f
-    }
-
-    /**
-     * 拖动跟随:页面随手指下移(封顶),达到最小距离后顶部亮起提示条。
-     *
-     * @param dy 相对按下点的竖直位移,向下为正
-     */
-    private fun updateBookmarkSwipeDrag(dy: Float) {
-        curPage.translationY = dy.coerceIn(0f, height.toFloat() * BOOKMARK_SWIPE_MAX_PULL_RATIO)
-        if (dy >= bookmarkSwipeMinDistance) {
-            showBookmarkSwipeHint()
-        } else {
-            hideBookmarkSwipeHint()
-        }
-    }
-
-    private fun showBookmarkSwipeHint() {
-        if (bookmarkSwipeHint.visibility != View.VISIBLE) {
-            // 当前页已有书签时下滑是取消,文案随状态切换;书签只在抬手时变更,拖动期间稳定。
-            bookmarkSwipeHint.text = if (curPage.hasBookmarkOnCurrentPage()) {
-                context.getString(R.string.bookmark_swipe_release_to_remove)
-            } else {
-                context.getString(R.string.bookmark_swipe_release_to_add)
-            }
-            // 提示条挂在阅读内容顶部(状态栏 + 页眉下方)而不是屏幕顶部——下拉时
-            // 页面移开露出的正是这块区域,提示条像从「阅读页上方」浮现。
-            bookmarkSwipeHint.updateLayoutParams<FrameLayout.LayoutParams> {
-                topMargin =
-                    bookmarkSwipeContentTop() + BOOKMARK_SWIPE_HINT_CONTENT_TOP_MARGIN_DP.dpToPx()
-            }
-            bookmarkSwipeHint.animate().cancel()
-            bookmarkSwipeHint.alpha = 0f
-            bookmarkSwipeHint.visibility = View.VISIBLE
-            bookmarkSwipeHint.animate()
-                .alpha(1f)
-                .setDuration(BOOKMARK_SWIPE_HINT_FADE_MS)
-                .start()
-        }
-    }
-
-    /** 阅读内容顶部相对 ReadView 顶部的距离:状态栏占位 + 页眉 + 内边距。 */
-    private fun bookmarkSwipeContentTop(): Int = curPage.headerHeight
-
-    private fun hideBookmarkSwipeHint() {
-        if (bookmarkSwipeHint.visibility != View.GONE) {
-            bookmarkSwipeHint.animate().cancel()
-            bookmarkSwipeHint.visibility = View.GONE
-        }
-    }
-
-    override fun performClick(): Boolean {
-        super.performClick()
-        return true
-    }
-
-    override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
-        super.onInitializeAccessibilityNodeInfo(info)
-        // The Canvas-rendered page is exposed as one stable text node.
-        info.className = TextView::class.java.name
-        info.text = accessibilityPageText
-        info.isScrollable = true
-        info.addAction(
-            AccessibilityNodeInfo.AccessibilityAction(
-                AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD,
-                context.getString(R.string.prev_page)
-            )
-        )
-        info.addAction(
-            AccessibilityNodeInfo.AccessibilityAction(
-                AccessibilityNodeInfo.ACTION_SCROLL_FORWARD,
-                context.getString(R.string.next_page)
-            )
-        )
-        info.addAction(
-            AccessibilityNodeInfo.AccessibilityAction(
-                AccessibilityNodeInfo.ACTION_CLICK,
-                context.getString(R.string.menu)
-            )
-        )
-    }
-
-    override fun onInitializeAccessibilityEvent(event: AccessibilityEvent) {
-        super.onInitializeAccessibilityEvent(event)
-        if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
-            event.contentChangeTypes = AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT
-        }
-    }
-
-    override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
-        return when (action) {
-            AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
-                pageDelegate?.prevPageByAnim(defaultAnimationSpeed)
-                true
-            }
-
-            AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> {
-                pageDelegate?.nextPageByAnim(defaultAnimationSpeed)
-                true
-            }
-
-            AccessibilityNodeInfo.ACTION_CLICK -> {
-                performClick()
-                pageDelegate?.dismissSnackBar()
-                eventListener.onEvent(ReaderEvent.ShowActionMenu)
-                true
-            }
-
-            else -> super.performAccessibilityAction(action, arguments)
-        }
-    }
-
-    fun cancelSelect(clearSearchResult: Boolean = false) {
-        if (isTextSelected || clearSearchResult) {
-            curPage.cancelSelect(clearSearchResult)
-            isTextSelected = false
-        }
-    }
-
-    /**
-     * 更新状态栏
-     */
-    fun upStatusBar() {
-        curPage.upStatusBar()
-        prevPage.upStatusBar()
-        nextPage.upStatusBar()
-    }
-
-    /**
-     * 保存开始位置
-     */
-    fun setStartPoint(x: Float, y: Float, invalidate: Boolean = true) {
-        startX = x
-        startY = y
-        lastX = x
-        lastY = y
-        touchX = x
-        touchY = y
-
-        if (invalidate) {
-            invalidate()
-        }
-    }
-
-    /**
-     * 保存当前位置
-     */
-    fun setTouchPoint(x: Float, y: Float, invalidate: Boolean = true) {
-        lastX = touchX
-        lastY = touchY
-        touchX = x
-        touchY = y
-        if (invalidate) {
-            invalidate()
-        }
-        pageDelegate?.onScroll()
-    }
-
-    /**
-     * 长按选择
-     */
-    private fun onLongPress() {
-        kotlin.runCatching {
-            curPage.longPress(startX, startY) { textPos: TextPos ->
-                isTextSelected = true
-                pressOnTextSelected = true
-                initialTextPos.upData(textPos)
-                val startPos = textPos.copy()
-                val endPos = textPos.copy()
-                val page = curPage.relativePage(textPos.relativePagePos)
-                val stringBuilder = StringBuilder()
-                var cIndex = textPos.columnIndex
-                var lineStart = textPos.lineIndex
-                var lineEnd = textPos.lineIndex
-                for (index in textPos.lineIndex - 1 downTo 0) {
-                    val textLine = page.getLine(index)
-                    if (textLine.isParagraphEnd) {
-                        break
-                    } else {
-                        stringBuilder.insert(0, textLine.text)
-                        lineStart -= 1
-                        cIndex += textLine.charSize
-                    }
-                }
-                for (index in textPos.lineIndex until page.lineSize) {
-                    val textLine = page.getLine(index)
-                    stringBuilder.append(textLine.text)
-                    lineEnd += 1
-                    if (textLine.isParagraphEnd) {
-                        break
-                    }
-                }
-                var start: Int
-                var end: Int
-                boundary.setText(stringBuilder.toString())
-                start = boundary.first()
-                end = boundary.next()
-                while (end != BreakIterator.DONE) {
-                    if (cIndex in start until end) {
-                        break
-                    }
-                    start = end
-                    end = boundary.next()
-                }
-                kotlin.run {
-                    var ci = 0
-                    for (index in lineStart..lineEnd) {
-                        val textLine = page.getLine(index)
-                        for (j in textLine.columns.indices) {
-                            if (ci == start) {
-                                startPos.lineIndex = index
-                                startPos.columnIndex = j
-                            } else if (ci == end - 1) {
-                                endPos.lineIndex = index
-                                endPos.columnIndex = j
-                                return@run
-                            }
-                            val column = textLine.getColumn(j)
-                            if (column is TextBaseColumn) {
-                                ci += column.charData.length
-                            } else {
-                                ci++
-                            }
-                        }
-                    }
-                }
-                curPage.selectStartMoveIndex(startPos)
-                curPage.selectEndMoveIndex(endPos)
-            }
-        }
-    }
-
-    /**
-     * 单击
-     */
-    private fun onSingleTapUp() {
-        when {
-            isTextSelected -> Unit
-            mcRect.contains(startX, startY) -> if (!isAbortAnim) {
-                click(ReadConfig.clickActionMC)
-            }
-
-            bcRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionBC)
-            }
-
-            blRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionBL)
-            }
-
-            brRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionBR)
-            }
-
-            mlRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionML)
-            }
-
-            mrRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionMR)
-            }
-
-            tlRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionTL)
-            }
-
-            tcRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionTC)
-            }
-
-            trRect.contains(startX, startY) -> {
-                click(ReadConfig.clickActionTR)
-            }
-        }
-    }
-
-    /**
-     * 点击
-     */
-    private fun click(action: Int) {
-        when (action) {
-            0 -> {
-                pageDelegate?.dismissSnackBar()
-                eventListener.onEvent(ReaderEvent.ShowActionMenu)
-            }
-
-            1 -> pageDelegate?.nextPageByAnim(defaultAnimationSpeed)
-            2 -> pageDelegate?.prevPageByAnim(defaultAnimationSpeed)
-            3 -> eventListener.onEvent(ReaderEvent.NextChapter)
-            4 -> eventListener.onEvent(ReaderEvent.PrevChapter)
-            5 -> eventListener.onEvent(ReaderEvent.ReadAloudPrevParagraph)
-            6 -> eventListener.onEvent(ReaderEvent.ReadAloudNextParagraph)
-            7 -> eventListener.onEvent(ReaderEvent.AddBookmark)
-            8 -> eventListener.onEvent(ReaderEvent.OpenContentEdit)
-            9 -> eventListener.onEvent(ReaderEvent.ChangeReplaceRuleState)
-            10 -> eventListener.onEvent(ReaderEvent.OpenChapterList)
-            11 -> eventListener.onEvent(ReaderEvent.OpenSearch)
-            12 -> eventListener.onEvent(ReaderEvent.SyncProgress)
-            13 -> eventListener.onEvent(ReaderEvent.ToggleReadAloudPause)
-        }
-    }
-
-    /**
-     * 选择文本
-     */
-    private fun selectText(x: Float, y: Float) {
-        curPage.selectText(x, y) { textPos ->
-            val compare = initialTextPos.compare(textPos)
-            when {
-                compare > 0 -> {
-                    curPage.selectStartMoveIndex(textPos)
-                    curPage.selectEndMoveIndex(
-                        initialTextPos.relativePagePos,
-                        initialTextPos.lineIndex,
-                        initialTextPos.columnIndex - 1
-                    )
-                }
-
-                else -> {
-                    curPage.selectStartMoveIndex(initialTextPos)
-                    curPage.selectEndMoveIndex(textPos)
-                }
-            }
-        }
-    }
-
-    /**
-     * 销毁事件
-     */
-    fun onDestroy() {
-        pageDelegate?.onDestroy()
-        curPage.cancelSelect()
-        invalidateTextPage()
-    }
-
-    /**
-     * 翻页动画完成后事件
-     * @param direction 翻页方向
-     */
-    fun fillPage(direction: PageDirection): Boolean {
-        return when (direction) {
-            PageDirection.PREV -> {
-                pageFactory.moveToPrev(true)
-            }
-
-            PageDirection.NEXT -> {
-                pageFactory.moveToNext(true)
-            }
-
-            else -> false
-        }
-    }
-
-    /**
-     * 更新翻页动画
-     */
-    fun upPageAnim(upRecorder: Boolean = false) {
-        isScroll = pageSource.pageAnim == 3
-        ChapterProvider.upLayout()
-        when (pageSource.pageAnim) {
-            PageAnim.coverPageAnim -> if (pageDelegate !is CoverPageDelegate) {
-                pageDelegate = CoverPageDelegate(this)
-            }
-
-            PageAnim.slidePageAnim -> if (pageDelegate !is SlidePageDelegate) {
-                pageDelegate = SlidePageDelegate(this)
-            }
-
-            PageAnim.simulationPageAnim -> if (pageDelegate !is SimulationPageDelegate) {
-                pageDelegate = SimulationPageDelegate(this)
-            }
-
-            PageAnim.scrollPageAnim -> if (pageDelegate !is ScrollPageDelegate) {
-                pageDelegate = ScrollPageDelegate(this)
-            }
-
-            PageAnim.fadePageAnim -> if (pageDelegate !is FadePageDelegate) {
-                pageDelegate = FadePageDelegate(this)
-            }
-
-            else -> if (pageDelegate !is NoAnimPageDelegate) {
-                pageDelegate = NoAnimPageDelegate(this)
-            }
-        }
-        (pageDelegate as? ScrollPageDelegate)?.noAnim = ReadConfig.noAnimScrollPage
-        if (upRecorder) {
-            (pageDelegate as? HorizontalPageDelegate)?.upRecorder()
-            autoPager.upRecorder()
-        }
-        pageDelegate?.setViewSize(width, height)
-        if (isScroll) {
-            curPage.setAutoPager(autoPager)
-        } else {
-            curPage.setAutoPager(null)
-        }
-        curPage.setIsScroll(isScroll)
-    }
-
-    /**
-     * 更新阅读内容
-     * @param relativePosition 相对位置 -1 上一页 0 当前页 1 下一页
-     * @param resetPageOffset 滚动阅读是是否重置位置
-     */
-    override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
-        post {
-            val text = pageFactory.curPage.text
-            updateAccessibilityPageText(text)
-            if (BuildConfig.DEBUG) {
-                // Device-independent readiness/page-change signal for the debug
-                // scenario runner (uiautomator does not expose ReadView on all
-                // devices). See tools/android/runner.py.
-                Log.i("LegadoDebug", "READER_PAGE " + text.replace("\n", " "))
-                if (text == context.getString(R.string.data_loading)) {
-                    // Locale-independent signal for the placeholder frame; the
-                    // scenario runner counts these. See tools/android/runner.py.
-                    Log.i("LegadoDebug", "READER_PAGE_PLACEHOLDER_FRAME")
-                }
-            }
-        }
-        if (isScroll && !isAutoPage) {
-            if (relativePosition == 0) {
-                curPage.setContent(pageFactory.curPage, resetPageOffset)
-            } else {
-                curPage.invalidateContentView()
-            }
-        } else {
-            when (relativePosition) {
-                -1 -> prevPage.setContent(pageFactory.prevPage)
-                1 -> nextPage.setContent(pageFactory.nextPage)
-                else -> {
-                    curPage.setContent(pageFactory.curPage, resetPageOffset)
-                    nextPage.setContent(pageFactory.nextPage)
-                    prevPage.setContent(pageFactory.prevPage)
-                }
-            }
-        }
-        callBack.screenOffTimerStart()
-    }
-
-    private fun updateAccessibilityPageText(text: String) {
-        if (accessibilityPageText == text) return
-        accessibilityPageText = text
-        if (isAttachedToWindow) {
-            sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)
-        }
-    }
-
-    private fun upProgress() {
-        curPage.setProgress(pageFactory.curPage)
-    }
-
-    /**
-     * 更新滑动距离
-     */
-    fun upPageSlopSquare() {
-        val pageTouchSlop = ReadConfig.pageTouchSlop
-        this.pageSlopSquare = if (pageTouchSlop == 0) slopSquare else pageTouchSlop
-        pageSlopSquare2 = this.pageSlopSquare * this.pageSlopSquare
-    }
-
-    /**
-     * 更新样式
-     */
-    fun upStyle() {
-        ChapterProvider.upStyle()
-        TipStyleProvider.upTipStyle()
-        curPage.upStyle()
-        prevPage.upStyle()
-        nextPage.upStyle()
-        // 角标可能随样式刷新而切换自定义图/默认图(选图或恢复默认后立即生效)
-        upBookmarkBadge()
-    }
-
-    /**
-     * 原子应用日夜主题颜色:先废弃旧正文录制缓存,再在同一主线程任务内更新
-     * 背景、正文画笔和页眉页脚,避免新背景与旧文字位图出现在同一帧。
-     */
-    fun applyThemeColors() {
-        ChapterProvider.upThemeColors()
-        TipStyleProvider.upTipStyle()
-        invalidateTextPage()
-        ReadSessionState.updateBackground(width, height)
-        curPage.apply {
-            upThemeColors()
-            upBg()
-        }
-        prevPage.apply {
-            upThemeColors()
-            upBg()
-        }
-        nextPage.apply {
-            upThemeColors()
-            upBg()
-        }
-        pageDelegate?.postInvalidate()
-        invalidate()
-    }
-
-    /**
-     * 更新背景
-     */
-    fun upBg() {
-        ReadSessionState.updateBackground(width, height)
-        curPage.upBg()
-        prevPage.upBg()
-        nextPage.upBg()
-        // 与页面同底:下拉书签手势把 curPage 移开后,露出的区域仍是页面背景而非主题色。
-        background = LayerDrawable(
-            arrayOf(
-                ReadSessionState.backgroundMeanColor.toDrawable(),
-                ReadSessionState.background
-            )
-        )
-    }
-
-    /**
-     * 更新背景透明度
-     */
-    fun upBgAlpha() {
-        curPage.upBgAlpha()
-        prevPage.upBgAlpha()
-        nextPage.upBgAlpha()
-    }
-
-    /**
-     * 更新时间信息
-     */
-    fun upTime() {
-        curPage.upTime()
-        prevPage.upTime()
-        nextPage.upTime()
-    }
-
-    /**
-     * 更新右上角书签角标
-     */
-    fun upBookmarkBadge() {
-        curPage.upBookmarkBadge()
-        prevPage.upBookmarkBadge()
-        nextPage.upBookmarkBadge()
-    }
-
-    /**
-     * 更新电量信息
-     */
-    fun upBattery(battery: Int) {
-        curPage.upBattery(battery)
-        prevPage.upBattery(battery)
-        nextPage.upBattery(battery)
-    }
-
-    /**
-     * 当前页内 行/列 对应的章节内位置(渲染面查询,翻页推进由外层负责)
-     */
-    fun posByLineColumn(line: Int, column: Int): Int {
-        return curPage.textPage.getPosByLineColumn(line, column)
-    }
-
-    /**
-     * @return 选择的文本
-     */
-    fun getSelectText(): String {
-        return curPage.selectedText
-    }
-
-    fun getSelectTextPos(): Int {
-        val selectStartPos = curPage.selectStartPos
-        return curPage.textPage.getPosByLineColumn(
-            selectStartPos.lineIndex,
-            selectStartPos.columnIndex
-        )
-    }
-
-    fun getCurVisiblePage(): TextPage {
-        return curPage.getCurVisiblePage()
-    }
-
-    fun getReadAloudPos(): Pair<Int, TextLine>? {
-        return curPage.getReadAloudPos()
-    }
-
-    fun invalidateTextPage() {
-        if (!ReadConfig.optimizeRender) {
-            return
-        }
-        pageFactory.run {
-            prevPage.invalidateAll()
-            curPage.invalidateAll()
-            nextPage.invalidateAll()
-            nextPlusPage.invalidateAll()
-        }
-    }
-
-    /**
-     * 供 [io.legado.app.ui.book.read.page.delegate.PageDelegate] 在翻到尽头时出站——
-     * 翻页委托不持有 eventListener,经这里转发(Track D·D1)。
-     */
-    internal fun requestAutoPageStop() {
-        eventListener.onEvent(ReaderEvent.AutoPageStop)
-    }
-
-    fun onScrollAnimStart() {
-        autoPager.pause()
-    }
-
-    fun onScrollAnimStop() {
-        autoPager.resume()
-    }
-
-    fun onPageChange() {
-        autoPager.reset()
-        submitRenderTask()
-    }
-
-    fun submitRenderTask() {
-        if (!ReadConfig.optimizeRender) {
-            return
-        }
-        curPage.submitRenderTask()
-    }
-
-    fun isLongScreenShot(): Boolean {
-        return curPage.isLongScreenShot()
-    }
-
-    override fun onLayoutPageCompleted(index: Int, page: TextPage) {
-        upProgressThrottle.invoke()
-    }
-
-    override val pageIndex: Int get() = pageSource.durPageIndex
-
-    override val currentChapter: TextChapter?
-        get() {
-            return if (callBack.isInitFinish) pageSource.textChapter(0) else null
-        }
-
-    override val nextChapter: TextChapter?
-        get() {
-            return if (callBack.isInitFinish) pageSource.textChapter(1) else null
-        }
-
-    override val prevChapter: TextChapter?
-        get() {
-            return if (callBack.isInitFinish) pageSource.textChapter(-1) else null
-        }
-
-    override fun hasNextChapter(): Boolean {
-        return pageSource.durChapterIndex < pageSource.simulatedChapterSize - 1
-    }
-
-    override fun hasPrevChapter(): Boolean {
-        return pageSource.durChapterIndex > 0
-    }
-
-    /**
-     * 与宿主的**瞬时 UI 副作用**协作面(Track D·D1)。
-     *
-     * 业务意图一律走 [ReaderEvent];这里只留不属于业务状态、由 View 直接驱动宿主的那几项,
-     * 外加首帧放行门闩 `isInitFinish`(入站状态查询,随 D2 数据面一起处理)。
-     */
-    interface CallBack {
-        val isInitFinish: Boolean
-        fun screenOffTimerStart()
-        fun showTextActionMenu()
-        fun upSystemUiVisibility()
-    }
-
-    private companion object {
-        /** 下滑切换书签所需的最小竖直位移。取得比翻页 slop 大,避免与轻微斜划抢手势。 */
-        const val BOOKMARK_SWIPE_MIN_DISTANCE_DP = 80f
-
-        /** 竖直位移需超过水平位移的这个倍数,才判定为下滑而非斜向翻页。 */
-        const val VERTICAL_DOMINANCE_RATIO = 1.5f
-
-        /** 顶部提示条相对阅读内容顶部的距离。 */
-        const val BOOKMARK_SWIPE_HINT_CONTENT_TOP_MARGIN_DP = 8
-
-        /** 顶部提示条淡入时长。 */
-        const val BOOKMARK_SWIPE_HINT_FADE_MS = 120L
-
-        /** 顶部提示条圆角半径。 */
-        const val BOOKMARK_SWIPE_HINT_CORNER_RADIUS_DP = 24f
-
-        /** 松手/取消后页面弹回原位的动画时长。 */
-        const val BOOKMARK_SWIPE_SPRING_BACK_MS = 180L
-
-        /** 下拉时页面最多跟随手指移动的比例,防止整页拖出屏幕。 */
-        const val BOOKMARK_SWIPE_MAX_PULL_RATIO = 0.6f
-    }
-}

+ 0 - 91
app/src/main/java/io/legado/app/ui/book/read/page/ReaderBookmarkBadge.kt

@@ -1,91 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.BitmapFactory
-import android.graphics.Canvas
-import android.graphics.drawable.BitmapDrawable
-import android.graphics.drawable.Drawable
-import com.caverock.androidsvg.SVG
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.utils.SvgUtils
-import java.io.File
-import java.io.FileInputStream
-import kotlin.math.max
-import kotlin.math.min
-
-/**
- * 书签角标图源:把用户自定义的角标图片(PNG/JPEG/WebP/SVG 等)解码为可绘制对象并缓存。
- *
- * 角标是 10×20dp 的小图,且 `upBookmarkBadge` 位于翻页渲染热路径上,所以按
- * `path + lastModified` 单条缓存——路径或文件内容变了才重新解码,避免每页读盘。
- * 返回 null 表示未自定义(或文件失效),由调用方回退到默认黄色书签。
- */
-object ReaderBookmarkBadge {
-
-    private class Cache(val key: String, val drawable: Drawable?)
-
-    @Volatile
-    private var cache: Cache? = null
-
-    fun drawable(context: Context): Drawable? {
-        val path = ReadConfig.bookmarkBadgeImage
-        if (path.isBlank()) {
-            cache = null
-            return null
-        }
-        val file = File(path)
-        if (!file.isFile) {
-            cache = null
-            return null
-        }
-        // 角标为 1:2 的丝带,宽度来自 bookmarkBadgeSize(dp)。尺寸参与缓存键,
-        // 调大小后同一路径也会重新解码到新分辨率。
-        val density = context.resources.displayMetrics.density
-        val sizeDp = ReadConfig.bookmarkBadgeSize.coerceAtLeast(1)
-        val targetW = (sizeDp * density).toInt().coerceAtLeast(1)
-        val targetH = (sizeDp * 2 * density).toInt().coerceAtLeast(1)
-        val key = "$path|${file.lastModified()}|${targetW}x$targetH"
-        cache?.takeIf { it.key == key }?.let { return it.drawable }
-        val bitmap = decode(file, targetW, targetH) ?: run {
-            cache = Cache(key, null)
-            return null
-        }
-        return BitmapDrawable(context.resources, bitmap).also { cache = Cache(key, it) }
-    }
-
-    private fun decode(file: File, targetW: Int, targetH: Int): Bitmap? =
-        decodeRaster(file.path, targetW, targetH) ?: decodeSvg(file, targetW, targetH)
-
-    /** 位图解码:先读边界,按目标大小的两倍做采样,避免用户选了大图撑爆内存。 */
-    private fun decodeRaster(path: String, targetW: Int, targetH: Int): Bitmap? {
-        val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
-        BitmapFactory.decodeFile(path, bounds)
-        if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
-        var sample = 1
-        while (bounds.outWidth / (sample * 2) >= targetW * 2 &&
-            bounds.outHeight / (sample * 2) >= targetH * 2
-        ) {
-            sample *= 2
-        }
-        val options = BitmapFactory.Options().apply { inSampleSize = sample }
-        return BitmapFactory.decodeFile(path, options)
-    }
-
-    /**
-     * SVG 解码:缩放到角标实际像素尺寸(小图需要放大到目标大小才清晰,
-     * [SvgUtils.createBitmap] 只缩不放,不适用)。位图解码失败时才走到这里。
-     */
-    private fun decodeSvg(file: File, targetW: Int, targetH: Int): Bitmap? = runCatching {
-        val svg = SVG.getFromInputStream(FileInputStream(file))
-        val size = SvgUtils.getSize(file.path) ?: return@runCatching null
-        val scale = min(targetW / size.width.toFloat(), targetH / size.height.toFloat())
-        val width = max(1, (size.width * scale).toInt())
-        val height = max(1, (size.height * scale).toInt())
-        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
-        svg.setDocumentWidth("100%")
-        svg.setDocumentHeight("100%")
-        svg.renderToCanvas(Canvas(bitmap))
-        bitmap
-    }.getOrNull()
-}

+ 0 - 46
app/src/main/java/io/legado/app/ui/book/read/page/ReaderEvent.kt

@@ -1,46 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-/**
- * `ReadView` 的出站业务意图(Track D·D1,见 docs/dev/mad-modernization-plan.md §Track D)。
- *
- * `ReadView` 只做绘制/手势/动画,不认识 `ReadBook`/`ReadAloud`:手势判定出的业务命令以事件发出,
- * 由外层(`ReadBookController`)翻译成 Intent 或会话调用。
- *
- * 不属于业务状态的**瞬时 UI 副作用**(系统栏可见性、息屏计时、文本选择菜单等)不走这里,
- * 仍由 `ReadView.CallBack` 与宿主直接协作。
- */
-sealed interface ReaderEvent {
-
-    data object ShowActionMenu : ReaderEvent
-
-    data object AutoPageStop : ReaderEvent
-
-    data object OpenChapterList : ReaderEvent
-
-    data object OpenContentEdit : ReaderEvent
-
-    data object OpenSearch : ReaderEvent
-
-    data object AddBookmark : ReaderEvent
-
-    /** 下滑手势:切换当前页书签。 */
-    data object ToggleBookmark : ReaderEvent
-
-    data object ChangeReplaceRuleState : ReaderEvent
-
-    data object NextChapter : ReaderEvent
-
-    data object PrevChapter : ReaderEvent
-
-    data object ReadAloudPrevParagraph : ReaderEvent
-
-    data object ReadAloudNextParagraph : ReaderEvent
-
-    data object ToggleReadAloudPause : ReaderEvent
-
-    data object SyncProgress : ReaderEvent
-}
-
-fun interface ReaderEventListener {
-    fun onEvent(event: ReaderEvent)
-}

+ 0 - 47
app/src/main/java/io/legado/app/ui/book/read/page/ReaderPageSource.kt

@@ -1,47 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-import io.legado.app.ui.book.read.page.entities.TextChapter
-
-/**
- * 渲染层的页数据入口 + 页位置命令口(Track D·D2,见 docs/dev/mad-modernization-plan.md §Track D)。
- *
- * 与出站的 [ReaderEvent] 相对:业务意图往外发、页数据往里读。`ReadView` 与
- * `TextPageFactory` 都不认识 `ReadBook`,由宿主(`ReadBookController`)实现本接口把数据喂进来、
- * 把页位置命令接下去。
- *
- * 这里刻意是**逐次读取**而不是不可变快照:取页由 `pageFactory` 在绘制期驱动,
- * 换成发布期快照会改变时序。快照化是 D2 之后的独立议题,不与本步捆绑。
- *
- * **为什么页位置命令不走 [ReaderEvent]**:`ReaderEvent` 是即发即忘的出站意图,而
- * [setPageIndex]/[moveToNextChapter]/[moveToPrevChapter] 必须**同步**生效——
- * `TextPageFactory.moveToNext/moveToPrev` 下达命令后立即用新位置取页并返回布尔给翻页委托
- * 决定动画。所以它们是同步命令口,不是事件。
- */
-interface ReaderPageSource {
-
-    val durChapterIndex: Int
-
-    val durPageIndex: Int
-
-    val simulatedChapterSize: Int
-
-    val pageAnim: Int
-
-    /** 加载中/出错时要顶替正文显示的消息;为 null 表示正常显示正文 */
-    val msg: String?
-
-    /** chapterOnDur: 0 当前章, 1 下一章, -1 上一章 */
-    fun textChapter(chapterOnDur: Int): TextChapter?
-
-    /** 章内翻页:把阅读位置移到本章第 [index] 页 */
-    fun setPageIndex(index: Int)
-
-    /**
-     * 跨章前进。实现方必须用 `upContentInPlace = false`——由 `TextPageFactory` 在命令返回后
-     * 自行决定是否 `upContent`,避免同一次翻页刷两遍内容。
-     */
-    fun moveToNextChapter(upContent: Boolean)
-
-    /** 跨章后退,`upContentInPlace = false` 的约定同 [moveToNextChapter] */
-    fun moveToPrevChapter(upContent: Boolean)
-}

+ 0 - 19
app/src/main/java/io/legado/app/ui/book/read/page/ResourceLoadFailureCache.kt

@@ -1,19 +0,0 @@
-package io.legado.app.ui.book.read.page
-
-internal class ResourceLoadFailureCache<K> {
-
-    private val failedKeys = HashSet<K>()
-
-    fun <V> load(key: K, loader: () -> V?): V? {
-        synchronized(failedKeys) {
-            if (key in failedKeys) return null
-        }
-        return loader().also { result ->
-            if (result == null) {
-                synchronized(failedKeys) {
-                    failedKeys.add(key)
-                }
-            }
-        }
-    }
-}

+ 0 - 23
app/src/main/java/io/legado/app/ui/book/read/page/api/DataSource.kt

@@ -1,23 +0,0 @@
-package io.legado.app.ui.book.read.page.api
-
-import io.legado.app.ui.book.read.page.entities.TextChapter
-
-interface DataSource {
-
-    val pageIndex: Int
-
-    val currentChapter: TextChapter?
-
-    val nextChapter: TextChapter?
-
-    val prevChapter: TextChapter?
-
-    val isScroll: Boolean
-
-    fun hasNextChapter(): Boolean
-
-    fun hasPrevChapter(): Boolean
-
-    fun upContent(relativePosition: Int = 0, resetPageOffset: Boolean = true)
-
-}

+ 0 - 26
app/src/main/java/io/legado/app/ui/book/read/page/api/PageFactory.kt

@@ -1,26 +0,0 @@
-package io.legado.app.ui.book.read.page.api
-
-abstract class PageFactory<DATA>(protected val dataSource: DataSource) {
-
-    abstract fun moveToFirst()
-
-    abstract fun moveToLast()
-
-    abstract fun moveToNext(upContent: Boolean): Boolean
-
-    abstract fun moveToPrev(upContent: Boolean): Boolean
-
-    abstract val nextPage: DATA
-
-    abstract val prevPage: DATA
-
-    abstract val curPage: DATA
-
-    abstract val nextPlusPage: DATA
-
-    abstract fun hasNext(): Boolean
-
-    abstract fun hasPrev(): Boolean
-
-    abstract fun hasNextPlus(): Boolean
-}

+ 0 - 117
app/src/main/java/io/legado/app/ui/book/read/page/delegate/CoverPageDelegate.kt

@@ -1,117 +0,0 @@
-package io.legado.app.ui.book.read.page.delegate
-
-import android.graphics.Canvas
-import android.graphics.drawable.GradientDrawable
-import androidx.core.graphics.withClip
-import androidx.core.graphics.withTranslation
-import io.legado.app.ui.book.read.page.ReadView
-import io.legado.app.ui.book.read.page.entities.PageDirection
-import io.legado.app.utils.screenshot
-
-class CoverPageDelegate(readView: ReadView) : HorizontalPageDelegate(readView) {
-    private val shadowDrawableR: GradientDrawable
-
-    init {
-        val shadowColors = intArrayOf(0x66111111, 0x00000000)
-        shadowDrawableR = GradientDrawable(
-            GradientDrawable.Orientation.LEFT_RIGHT, shadowColors
-        )
-        shadowDrawableR.gradientType = GradientDrawable.LINEAR_GRADIENT
-    }
-
-    override fun onDraw(canvas: Canvas) {
-        if (!isRunning) return
-        val offsetX = touchX - startX
-
-        if ((mDirection == PageDirection.NEXT && offsetX > 0)
-            || (mDirection == PageDirection.PREV && offsetX < 0)
-        ) {
-            return
-        }
-
-        val distanceX = if (offsetX > 0) offsetX - viewWidth else offsetX + viewWidth
-        if (mDirection == PageDirection.PREV) {
-            if (offsetX <= viewWidth) {
-                canvas.withTranslation(distanceX) {
-                    prevRecorder.draw(canvas)
-                }
-                addShadow(distanceX, canvas)
-            } else {
-                prevRecorder.draw(canvas)
-            }
-        } else if (mDirection == PageDirection.NEXT) {
-            val width = nextRecorder.width.toFloat()
-            val height = nextRecorder.height.toFloat()
-            canvas.withClip(width + offsetX, 0f, width, height) {
-                nextRecorder.draw(this)
-            }
-            canvas.withTranslation(distanceX - viewWidth) {
-                curRecorder.draw(this)
-            }
-            addShadow(distanceX, canvas)
-        }
-    }
-
-    override fun setBitmap() {
-        when (mDirection) {
-            PageDirection.PREV -> {
-                prevPage.screenshot(prevRecorder)
-            }
-
-            PageDirection.NEXT -> {
-                nextPage.screenshot(nextRecorder)
-                curPage.screenshot(curRecorder)
-            }
-
-            else -> Unit
-        }
-    }
-
-    private fun addShadow(left: Float, canvas: Canvas) {
-        if (left == 0f) return
-        val dx = if (left < 0) {
-            left + viewWidth
-        } else {
-            left
-        }
-        canvas.withTranslation(dx) {
-            shadowDrawableR.draw(canvas)
-        }
-    }
-
-    override fun setViewSize(width: Int, height: Int) {
-        super.setViewSize(width, height)
-        shadowDrawableR.setBounds(0, 0, 30, viewHeight)
-    }
-
-    override fun onAnimStop() {
-        if (!isCancel) {
-            readView.fillPage(mDirection)
-        }
-    }
-
-    override fun onAnimStart(animationSpeed: Int) {
-        val distanceX: Float
-        when (mDirection) {
-            PageDirection.NEXT -> distanceX =
-                if (isCancel) {
-                    var dis = viewWidth - startX + touchX
-                    if (dis > viewWidth) {
-                        dis = viewWidth.toFloat()
-                    }
-                    viewWidth - dis
-                } else {
-                    -(touchX + (viewWidth - startX))
-                }
-
-            else -> distanceX =
-                if (isCancel) {
-                    -(touchX - startX)
-                } else {
-                    viewWidth - (touchX - startX)
-                }
-        }
-        startScroll(touchX.toInt(), 0, distanceX.toInt(), 0, animationSpeed)
-    }
-
-}

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно