HapeLee 2 месяцев назад
Родитель
Сommit
9ce52559c9
19 измененных файлов с 653 добавлено и 139 удалено
  1. 182 29
      app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt
  2. 5 11
      app/src/main/java/io/legado/app/feature/reader/ScrollPageDrawCache.kt
  3. 7 2
      app/src/main/java/io/legado/app/feature/reader/core/layout/ReaderChapterBlockMeasurer.kt
  4. 5 0
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderPage.kt
  5. 12 3
      app/src/main/java/io/legado/app/feature/reader/core/model/ReaderUnderlineRun.kt
  6. 65 0
      app/src/main/java/io/legado/app/feature/reader/core/readaloud/ReaderVisibleTextPositionPolicy.kt
  7. 3 1
      app/src/main/java/io/legado/app/feature/reader/core/selection/ReaderSelection.kt
  8. 37 1
      app/src/main/java/io/legado/app/feature/reader/core/transition/PageCurlGeometry.kt
  9. 7 3
      app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderStyleRangeMapper.kt
  10. 29 12
      app/src/main/java/io/legado/app/model/ReadBook.kt
  11. 88 57
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  12. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt
  13. 14 13
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  14. 4 3
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  15. 15 0
      app/src/test/java/io/legado/app/feature/reader/ScrollPageDrawCacheTest.kt
  16. 27 2
      app/src/test/java/io/legado/app/feature/reader/core/model/ReaderUnderlineRunTest.kt
  17. 92 0
      app/src/test/java/io/legado/app/feature/reader/core/readaloud/ReaderVisibleTextPositionPolicyTest.kt
  18. 26 1
      app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionTest.kt
  19. 34 0
      app/src/test/java/io/legado/app/feature/reader/core/transition/PageCurlGeometryTest.kt

+ 182 - 29
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt

@@ -95,6 +95,7 @@ import io.legado.app.feature.reader.core.gesture.ReaderPageViewportLayout
 import io.legado.app.feature.reader.core.gesture.ReaderTapAction
 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.ReaderTapActionGrid
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderEmphasisUnderlineRun
 import io.legado.app.feature.reader.core.model.ReaderImageDrawLayout
 import io.legado.app.feature.reader.core.model.ReaderImageDrawLayout
 import io.legado.app.feature.reader.core.model.ReaderNineSliceLayout
 import io.legado.app.feature.reader.core.model.ReaderNineSliceLayout
 import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderPage
@@ -107,8 +108,9 @@ 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.ReaderTipRow
 import io.legado.app.feature.reader.core.model.ReaderTipRowLayout
 import io.legado.app.feature.reader.core.model.ReaderTipRowLayout
 import io.legado.app.feature.reader.core.model.ReaderTipVisual
 import io.legado.app.feature.reader.core.model.ReaderTipVisual
-import io.legado.app.feature.reader.core.model.emphasisUnderlineRuns
 import io.legado.app.feature.reader.core.model.textBackgroundRuns
 import io.legado.app.feature.reader.core.model.textBackgroundRuns
+import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPosition
+import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPositionPolicy
 import io.legado.app.feature.reader.core.selection.ReaderPageChangeOrigin
 import io.legado.app.feature.reader.core.selection.ReaderPageChangeOrigin
 import io.legado.app.feature.reader.core.selection.ReaderSelection
 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.ReaderSelectionEndpoint
@@ -200,6 +202,7 @@ fun ReaderCanvasSurface(
     noAnimationScrollPage: Boolean,
     noAnimationScrollPage: Boolean,
     externalPageTurns: Flow<ReaderTurnDirection>,
     externalPageTurns: Flow<ReaderTurnDirection>,
     externalSelectionCancels: Flow<Unit>,
     externalSelectionCancels: Flow<Unit>,
+    onVisibleBodyTextPositionProvider: ((() -> ReaderVisibleTextPosition?)?) -> Unit,
 ) {
 ) {
     // 滚动跨页同步换窗:跨页帧内宿主回调直接返回新窗口,先写入 pending 供绘制与
     // 滚动跨页同步换窗:跨页帧内宿主回调直接返回新窗口,先写入 pending 供绘制与
     // 手势立即使用;宿主 StateFlow 回声(同一实例)或外部窗口变化会将其清除。
     // 手势立即使用;宿主 StateFlow 回声(同一实例)或外部窗口变化会将其清除。
@@ -228,6 +231,10 @@ fun ReaderCanvasSurface(
     var displayOffset by remember { mutableFloatStateOf(0f) }
     var displayOffset by remember { mutableFloatStateOf(0f) }
     var transition by remember { mutableStateOf(ReaderPageTransition()) }
     var transition by remember { mutableStateOf(ReaderPageTransition()) }
     var pageMotionJob by remember { mutableStateOf<Job?>(null) }
     var pageMotionJob by remember { mutableStateOf<Job?>(null) }
+    // The first curl frame starts at a corner. Animate it into the safe fold position so
+    // the entering page is revealed instead of popping in when horizontal capture begins.
+    var curlRevealProgress by remember { mutableFloatStateOf(1f) }
+    var curlRevealJob by remember { mutableStateOf<Job?>(null) }
     var pendingTurn by remember { mutableStateOf<ReaderTurnDirection?>(null) }
     var pendingTurn by remember { mutableStateOf<ReaderTurnDirection?>(null) }
     var pendingTurnOrigin by remember { mutableStateOf<ReaderPageId?>(null) }
     var pendingTurnOrigin by remember { mutableStateOf<ReaderPageId?>(null) }
     val latestPreviousPage by rememberUpdatedState(onPreviousPage)
     val latestPreviousPage by rememberUpdatedState(onPreviousPage)
@@ -259,6 +266,7 @@ fun ReaderCanvasSurface(
             displayOffset = 0f
             displayOffset = 0f
             transition = ReaderPageTransition()
             transition = ReaderPageTransition()
             bookmarkReturnJob?.cancel()
             bookmarkReturnJob?.cancel()
+            curlRevealJob?.cancel()
             bookmarkOffset = 0f
             bookmarkOffset = 0f
             bookmarkArmed = false
             bookmarkArmed = false
         }
         }
@@ -279,6 +287,17 @@ fun ReaderCanvasSurface(
     // 零重组零重绘(对照 shutiao 的 contentOffset 语义)。
     // 零重组零重绘(对照 shutiao 的 contentOffset 语义)。
     val scrollOffsetState = remember { mutableFloatStateOf(0f) }
     val scrollOffsetState = remember { mutableFloatStateOf(0f) }
     var scrollOffset by scrollOffsetState
     var scrollOffset by scrollOffsetState
+    val latestVisibleBodyTextPosition by rememberUpdatedState {
+        if (transitionMode == ReaderTransitionMode.SCROLL) {
+            ReaderVisibleTextPositionPolicy.firstVisibleBodyText(currentPageWindow(), scrollOffset)
+        } else {
+            null
+        }
+    }
+    DisposableEffect(onVisibleBodyTextPositionProvider) {
+        onVisibleBodyTextPositionProvider { latestVisibleBodyTextPosition() }
+        onDispose { onVisibleBodyTextPositionProvider(null) }
+    }
     // 滚动跨页折算标志:applyScrollResult 完成一次同步换窗后置位,由 current.id
     // 滚动跨页折算标志:applyScrollResult 完成一次同步换窗后置位,由 current.id
     // 效应消费——据此区分"自己跨页"与"外部换窗",后者才把滚动偏移归零。
     // 效应消费——据此区分"自己跨页"与"外部换窗",后者才把滚动偏移归零。
     var scrollOwnCrossing by remember { mutableStateOf(false) }
     var scrollOwnCrossing by remember { mutableStateOf(false) }
@@ -344,6 +363,18 @@ fun ReaderCanvasSurface(
     }
     }
     fun settlePageTurn(decision: ReaderTransitionDecision) {
     fun settlePageTurn(decision: ReaderTransitionDecision) {
         pageMotionJob?.cancel()
         pageMotionJob?.cancel()
+        curlRevealJob?.cancel()
+        if (transitionMode == ReaderTransitionMode.SIMULATION) {
+            transition.direction?.let { direction ->
+                curlTouchX = ReaderCurlTouchPolicy.revealX(
+                    direction,
+                    curlTouchX,
+                    transition.pageExtentPx,
+                    curlRevealProgress,
+                )
+            }
+            curlRevealProgress = 1f
+        }
         pendingTurn = transition.direction.takeIf { decision.commit }
         pendingTurn = transition.direction.takeIf { decision.commit }
         pendingTurnOrigin = latestPages.current?.id
         pendingTurnOrigin = latestPages.current?.id
         if (transitionMode == ReaderTransitionMode.NONE) {
         if (transitionMode == ReaderTransitionMode.NONE) {
@@ -410,6 +441,7 @@ fun ReaderCanvasSurface(
         if ((if (direction == ReaderTurnDirection.PREVIOUS) window.previous else window.next) == null) return
         if ((if (direction == ReaderTurnDirection.PREVIOUS) window.previous else window.next) == null) return
         val width = window.current?.widthPx?.toFloat() ?: return
         val width = window.current?.widthPx?.toFloat() ?: return
         if (transitionMode == ReaderTransitionMode.SIMULATION) {
         if (transitionMode == ReaderTransitionMode.SIMULATION) {
+            curlRevealProgress = 1f
             curlTouchX = ReaderCurlTouchPolicy.programmaticX(direction, width)
             curlTouchX = ReaderCurlTouchPolicy.programmaticX(direction, width)
             curlTouchY = ReaderCurlTouchPolicy.programmaticY(
             curlTouchY = ReaderCurlTouchPolicy.programmaticY(
                 direction, curlTouchY, window.current.heightPx.toFloat(),
                 direction, curlTouchY, window.current.heightPx.toFloat(),
@@ -423,6 +455,17 @@ fun ReaderCanvasSurface(
         displayOffset = 0f
         displayOffset = 0f
         settlePageTurn(ReaderTransitionDecision(target, commit = true))
         settlePageTurn(ReaderTransitionDecision(target, commit = true))
     }
     }
+
+    fun startCurlRevealSnap() {
+        curlRevealJob?.cancel()
+        curlRevealProgress = 0f
+        curlRevealJob = animationScope.launch {
+            Animatable(0f).animateTo(
+                1f,
+                tween(100, easing = FastOutSlowInEasing),
+            ) { curlRevealProgress = value }
+        }
+    }
     fun applyScrollResult(result: ReaderScrollResult, window: ReaderPageWindow) {
     fun applyScrollResult(result: ReaderScrollResult, window: ReaderPageWindow) {
         scrollOffset = result.offsetPx
         scrollOffset = result.offsetPx
         // 跨页换窗同步完成:宿主回调当帧返回新窗口,写入 pending 供本帧之后的
         // 跨页换窗同步完成:宿主回调当帧返回新窗口,写入 pending 供本帧之后的
@@ -705,6 +748,8 @@ fun ReaderCanvasSurface(
                 val down = awaitFirstDown(requireUnconsumed = false)
                 val down = awaitFirstDown(requireUnconsumed = false)
                 latestReaderInteraction()
                 latestReaderInteraction()
                 pageMotionJob?.cancel()
                 pageMotionJob?.cancel()
+                curlRevealJob?.cancel()
+                curlRevealProgress = 1f
                 // 翻页收尾被打断时在此同步提交;宿主当帧返回新窗口,但组合要等下一帧,
                 // 翻页收尾被打断时在此同步提交;宿主当帧返回新窗口,但组合要等下一帧,
                 // 手势必须改用返回的窗口命中,否则长按会选中已不在屏幕上的旧页。
                 // 手势必须改用返回的窗口命中,否则长按会选中已不在屏幕上的旧页。
                 val turnedWindow = completePendingTurn()
                 val turnedWindow = completePendingTurn()
@@ -894,6 +939,7 @@ fun ReaderCanvasSurface(
                             if (horizontalTurn) {
                             if (horizontalTurn) {
                                 horizontalDrag = ReaderHorizontalDrag.capture(total.x)
                                 horizontalDrag = ReaderHorizontalDrag.capture(total.x)
                                 horizontalCapturedY = change.position.y
                                 horizontalCapturedY = change.position.y
+                                if (transitionMode == ReaderTransitionMode.SIMULATION) startCurlRevealSnap()
                             }
                             }
                         }
                         }
                         if (bookmarkDrag) {
                         if (bookmarkDrag) {
@@ -922,6 +968,7 @@ fun ReaderCanvasSurface(
                                 if (horizontalTurn) {
                                 if (horizontalTurn) {
                                     horizontalDrag = ReaderHorizontalDrag.capture(total.x)
                                     horizontalDrag = ReaderHorizontalDrag.capture(total.x)
                                     horizontalCapturedY = change.position.y
                                     horizontalCapturedY = change.position.y
+                                    if (transitionMode == ReaderTransitionMode.SIMULATION) startCurlRevealSnap()
                                 }
                                 }
                             }
                             }
                         }
                         }
@@ -932,7 +979,11 @@ fun ReaderCanvasSurface(
                             ) ?: ReaderPageTransition(pageExtentPx = size.width.toFloat())
                             ) ?: ReaderPageTransition(pageExtentPx = size.width.toFloat())
                             transition.direction?.takeIf { transitionMode == ReaderTransitionMode.SIMULATION }
                             transition.direction?.takeIf { transitionMode == ReaderTransitionMode.SIMULATION }
                                 ?.let {
                                 ?.let {
-                                    curlTouchX = change.position.x
+                                    curlTouchX = ReaderCurlTouchPolicy.dragX(
+                                        it,
+                                        change.position.x,
+                                        size.width.toFloat(),
+                                    )
                                     curlCornerY = ReaderCurlTouchPolicy.cornerY(
                                     curlCornerY = ReaderCurlTouchPolicy.cornerY(
                                         it, horizontalCapturedY, size.height.toFloat(),
                                         it, horizontalCapturedY, size.height.toFloat(),
                                     )
                                     )
@@ -968,8 +1019,16 @@ fun ReaderCanvasSurface(
                     if (!released) {
                     if (!released) {
                         bookmarkArmed = false
                         bookmarkArmed = false
                         bookmarkOffset = 0f
                         bookmarkOffset = 0f
-                        displayOffset = 0f
-                        transition = ReaderPageTransition()
+                        // A competing gesture or pointer cancellation does not deliver UP.
+                        // Do not discard an in-progress horizontal curl here: it has the same
+                        // visual contract as a released-but-uncommitted turn and must return
+                        // through the curl's natural cancel path (previous-page curls go left).
+                        if (horizontalTurn && transition.dragging) {
+                            settlePageTurn(ReaderTransitionDecision(0f, commit = false))
+                        } else {
+                            displayOffset = 0f
+                            transition = ReaderPageTransition()
+                        }
                     }
                     }
                 }
                 }
                 if (released) latestReaderInteraction()
                 if (released) latestReaderInteraction()
@@ -1087,7 +1146,27 @@ fun ReaderCanvasSurface(
                 )
                 )
             }
             }
         } else if (transitionMode == ReaderTransitionMode.SIMULATION && transition.direction != null) {
         } else if (transitionMode == ReaderTransitionMode.SIMULATION && transition.direction != null) {
-            SimulationPageStack(pages, transition.direction!!, curlTouchX, curlTouchY, curlCornerY, backgroundColor, pageBackgroundImage, backgroundImageAlpha, selectionColor, textAccentColor, textSelection, cachedImage, loadImage)
+            SimulationPageStack(
+                pages,
+                transition.direction!!,
+                displayOffset,
+                ReaderCurlTouchPolicy.revealX(
+                    transition.direction!!,
+                    curlTouchX,
+                    current.widthPx.toFloat(),
+                    curlRevealProgress
+                ),
+                curlTouchY,
+                curlCornerY,
+                backgroundColor,
+                pageBackgroundImage,
+                backgroundImageAlpha,
+                selectionColor,
+                textAccentColor,
+                textSelection,
+                cachedImage,
+                loadImage
+            )
         } else {
         } else {
             @Composable
             @Composable
             fun PageLayer(page: ReaderPage, transform: ReaderPageTransform, offsetY: Float = 0f) {
             fun PageLayer(page: ReaderPage, transform: ReaderPageTransform, offsetY: Float = 0f) {
@@ -1261,20 +1340,17 @@ private fun ScrollPageStack(
             val cachedSources = sources.mapNotNull { source ->
             val cachedSources = sources.mapNotNull { source ->
                 ReaderTextBackgroundLoader.cached(source)?.let { source to it }
                 ReaderTextBackgroundLoader.cached(source)?.let { source to it }
             }.toMap()
             }.toMap()
-            if (cachedSources.isNotEmpty()) data.textBackgroundBitmaps.value = cachedSources
+            if (cachedSources.isNotEmpty()) data.textBackgroundRevision.value++
             val loadedSources = withContext(Dispatchers.IO) {
             val loadedSources = withContext(Dispatchers.IO) {
                 sources.mapNotNull { source ->
                 sources.mapNotNull { source ->
                     ReaderTextBackgroundLoader.load(source)?.let { source to it }
                     ReaderTextBackgroundLoader.load(source)?.let { source to it }
                 }.toMap()
                 }.toMap()
             }
             }
-            if (loadedSources.isNotEmpty()) data.textBackgroundBitmaps.value = loadedSources
+            if (loadedSources.isNotEmpty()) data.textBackgroundRevision.value++
             page.elements.filterIsInstance<ReaderElement.Image>().forEach { element ->
             page.elements.filterIsInstance<ReaderElement.Image>().forEach { element ->
-                val bitmap = (cachedImage(element) ?: loadImage(element))
-                    ?.takeUnless(Bitmap::isRecycled)
-                    ?: return@forEach
-                if (data.images.value[element] !== bitmap) {
-                    data.images.value = data.images.value + (element to bitmap)
-                }
+                // The controller owns inline bitmaps in a byte-bounded LRU. Do not retain
+                // another strong reference for every cached draw page.
+                if (cachedImage(element) == null) loadImage(element)
             }
             }
         }
         }
     }
     }
@@ -1290,14 +1366,22 @@ private fun ScrollPageStack(
         val activeSelection = selectionProvider()
         val activeSelection = selectionProvider()
         fun drawOne(page: ReaderPage, stackOffsetY: Float) {
         fun drawOne(page: ReaderPage, stackOffsetY: Float) {
             val data = cache.ensure(page)
             val data = cache.ensure(page)
-            val selectedBounds = if (activeSelection != null || data.hasSelectedElements) {
+            val selectedBounds = if (activeSelection != null || page.searchStart != null) {
                 data.textElements
                 data.textElements
-                    .filter { it.selected || activeSelection?.contains(it) == true }
+                    .filter { page.isSearchResult(it) || activeSelection?.contains(it) == true }
                     .map(ReaderElement.Text::bounds)
                     .map(ReaderElement.Text::bounds)
                     .mergeSelectionBounds()
                     .mergeSelectionBounds()
             } else emptyList()
             } else emptyList()
             withTransform({ translate(0f, stackOffsetY) }) {
             withTransform({ translate(0f, stackOffsetY) }) {
-                drawScrollPageContent(page, data, selection, readAloud, activeSelection, selectedBounds)
+                drawScrollPageContent(
+                    page,
+                    data,
+                    selection,
+                    readAloud,
+                    activeSelection,
+                    selectedBounds,
+                    cachedImage
+                )
             }
             }
         }
         }
         window.previous?.let { drawOne(it, -it.scrollExtentPx) }
         window.previous?.let { drawOne(it, -it.scrollExtentPx) }
@@ -1313,15 +1397,16 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
     readAloud: Color,
     readAloud: Color,
     activeSelection: ReaderSelection?,
     activeSelection: ReaderSelection?,
     selectedBounds: List<ReaderRect>,
     selectedBounds: List<ReaderRect>,
+    cachedImage: (ReaderElement.Image) -> Bitmap?,
 ) {
 ) {
     val native = drawContext.canvas.nativeCanvas
     val native = drawContext.canvas.nativeCanvas
-    val bitmaps = data.textBackgroundBitmaps.value
+    data.textBackgroundRevision.value
     data.textBackgrounds.forEach { run ->
     data.textBackgrounds.forEach { run ->
-        bitmaps[run.image.source]?.let { bitmap ->
+        ReaderTextBackgroundLoader.cached(run.image.source)?.let { bitmap ->
             drawTextBackground(native, bitmap, run, data.textBackgroundPaint)
             drawTextBackground(native, bitmap, run, data.textBackgroundPaint)
         }
         }
     }
     }
-    data.textElements.mergeBackgroundBounds().forEach { band ->
+    data.textBackgroundBands.forEach { band ->
         drawRect(
         drawRect(
             Color(band.colorArgb),
             Color(band.colorArgb),
             Offset(band.bounds.left, band.bounds.top),
             Offset(band.bounds.left, band.bounds.top),
@@ -1331,15 +1416,15 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
     selectedBounds.forEach { rect ->
     selectedBounds.forEach { rect ->
         drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
         drawRect(selection, Offset(rect.left, rect.top), Size(rect.width, rect.height))
     }
     }
-    val images = data.images.value
     page.elements.forEach { e -> when (e) {
     page.elements.forEach { e -> when (e) {
         is ReaderElement.Text -> {
         is ReaderElement.Text -> {
             val paint = data.paints.getValue(e.style)
             val paint = data.paints.getValue(e.style)
-            paint.color = e.resolvedColorArgb(readAloud.toArgb())
+            paint.color = page.resolvedColorArgb(e, readAloud.toArgb())
             paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
             paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
             native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
             native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
         }
         }
-        is ReaderElement.Image -> images[e]?.let { bitmap ->
+
+        is ReaderElement.Image -> cachedImage(e)?.let { bitmap ->
             ReaderImageDrawLayout.fitCenter(e.bounds, bitmap.width, bitmap.height)?.let { layout ->
             ReaderImageDrawLayout.fitCenter(e.bounds, bitmap.width, bitmap.height)?.let { layout ->
                 drawImage(
                 drawImage(
                     image = bitmap.asImageBitmap(),
                     image = bitmap.asImageBitmap(),
@@ -1368,7 +1453,7 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
         }
         }
         is ReaderElement.Rule -> Unit
         is ReaderElement.Rule -> Unit
     } }
     } }
-    data.emphasisUnderlines.forEach { run ->
+    page.dynamicEmphasisUnderlineRuns().forEach { run ->
         drawLine(
         drawLine(
             color = Color(run.style.colorArgb),
             color = Color(run.style.colorArgb),
             start = Offset(run.startPx, run.yPx),
             start = Offset(run.startPx, run.yPx),
@@ -1385,6 +1470,7 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawScrollPageConte
 private fun SimulationPageStack(
 private fun SimulationPageStack(
     pages: ReaderPageWindow,
     pages: ReaderPageWindow,
     direction: ReaderTurnDirection,
     direction: ReaderTurnDirection,
+    pageOffsetPx: Float,
     touchX: Float,
     touchX: Float,
     touchY: Float,
     touchY: Float,
     cornerY: Float,
     cornerY: Float,
@@ -1414,7 +1500,39 @@ private fun SimulationPageStack(
             )
             )
         }
         }
         if (frame == null) {
         if (frame == null) {
-            ReaderPageCanvas(basePage, background, backgroundImage, backgroundImageAlpha, selection, readAloud, Modifier.fillMaxSize(), activeSelection, cachedImage, loadImage)
+            // A degenerate Bezier frame used to draw only the base page, which made the
+            // entering page disappear for an entire drag frame. Keep the destination visible
+            // with a cheap horizontal fallback until the next valid curl frame arrives.
+            ReaderPageCanvas(
+                revealPage,
+                background,
+                backgroundImage,
+                backgroundImageAlpha,
+                selection,
+                readAloud,
+                Modifier.fillMaxSize(),
+                activeSelection,
+                cachedImage,
+                loadImage
+            )
+            val baseTranslation = when (direction) {
+                ReaderTurnDirection.NEXT -> pageOffsetPx
+                ReaderTurnDirection.PREVIOUS -> pageOffsetPx - width
+            }
+            ReaderPageCanvas(
+                basePage,
+                background,
+                backgroundImage,
+                backgroundImageAlpha,
+                selection,
+                readAloud,
+                Modifier
+                    .fillMaxSize()
+                    .graphicsLayer { translationX = baseTranslation },
+                activeSelection,
+                cachedImage,
+                loadImage,
+            )
             return@BoxWithConstraints
             return@BoxWithConstraints
         }
         }
         val paths = remember(frame, width, height) { frame.renderPaths(width, height) }
         val paths = remember(frame, width, height) { frame.renderPaths(width, height) }
@@ -1605,9 +1723,15 @@ private fun ReaderPageCanvas(
     val decorationDrawCache = remember(page.elements) {
     val decorationDrawCache = remember(page.elements) {
         ReaderPageDecorationDrawCache.create(page)
         ReaderPageDecorationDrawCache.create(page)
     }
     }
-    val selectedTextBounds = remember(textElements, activeSelection) {
+    val textBackgroundBands = remember(textElements) { textElements.mergeBackgroundBounds() }
+    val selectedTextBounds = remember(
+        textElements,
+        activeSelection,
+        page.searchStart,
+        page.searchEndInclusive,
+    ) {
         textElements
         textElements
-            .filter { it.selected || activeSelection?.contains(it) == true }
+            .filter { page.isSearchResult(it) || activeSelection?.contains(it) == true }
             .map(ReaderElement.Text::bounds)
             .map(ReaderElement.Text::bounds)
             .mergeSelectionBounds()
             .mergeSelectionBounds()
     }
     }
@@ -1649,7 +1773,7 @@ private fun ReaderPageCanvas(
                 drawTextBackground(native, bitmap, run, textBackgroundPaint)
                 drawTextBackground(native, bitmap, run, textBackgroundPaint)
             }
             }
         }
         }
-        textElements.mergeBackgroundBounds().forEach { band ->
+        textBackgroundBands.forEach { band ->
             drawRect(
             drawRect(
                 Color(band.colorArgb),
                 Color(band.colorArgb),
                 Offset(band.bounds.left, band.bounds.top),
                 Offset(band.bounds.left, band.bounds.top),
@@ -1662,7 +1786,7 @@ private fun ReaderPageCanvas(
         page.elements.forEach { e -> when (e) {
         page.elements.forEach { e -> when (e) {
             is ReaderElement.Text -> {
             is ReaderElement.Text -> {
                 val paint = paints.getValue(e.style)
                 val paint = paints.getValue(e.style)
-                paint.color = e.resolvedColorArgb(readAloud.toArgb())
+                paint.color = page.resolvedColorArgb(e, readAloud.toArgb())
                 paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
                 paint.isUnderlineText = e.style.nativeUnderline || e.drawsLinkUnderline
                 native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
                 native.drawText(e.value, e.bounds.left, e.baselinePx, paint)
             }
             }
@@ -1695,7 +1819,7 @@ private fun ReaderPageCanvas(
             }
             }
             is ReaderElement.Rule -> Unit
             is ReaderElement.Rule -> Unit
         } }
         } }
-        page.emphasisUnderlineRuns().forEach { run ->
+        page.dynamicEmphasisUnderlineRuns().forEach { run ->
             drawLine(
             drawLine(
                 color = Color(run.style.colorArgb),
                 color = Color(run.style.colorArgb),
                 start = Offset(run.startPx, run.yPx),
                 start = Offset(run.startPx, run.yPx),
@@ -1736,6 +1860,35 @@ fun ReaderBackgroundSurface(
 private fun Drawable.isolatedCopy(): Drawable =
 private fun Drawable.isolatedCopy(): Drawable =
     constantState?.newDrawable()?.mutate() ?: mutate()
     constantState?.newDrawable()?.mutate() ?: mutate()
 
 
+private fun ReaderPage.isSearchResult(text: ReaderElement.Text): Boolean {
+    val start = searchStart ?: return false
+    val end = searchEndInclusive ?: return false
+    val textEnd = text.chapterPosition + text.value.length - 1
+    return text.chapterPosition <= maxOf(start, end) && textEnd >= minOf(start, end)
+}
+
+private fun ReaderPage.isReadAloud(text: ReaderElement.Text): Boolean =
+    !text.emphasized && readAloudParagraphIndex != null && text.paragraphIndex == readAloudParagraphIndex
+
+private fun ReaderPage.resolvedColorArgb(text: ReaderElement.Text, accentColorArgb: Int): Int =
+    if (text.link != null || isSearchResult(text) || isReadAloud(text)) accentColorArgb else text.style.colorArgb
+
+private fun ReaderPage.dynamicEmphasisUnderlineRuns(): List<ReaderEmphasisUnderlineRun> {
+    val style = emphasisUnderlineStyle ?: return emptyList()
+    val lines = LinkedHashMap<Pair<Float, Float>, MutableList<ReaderElement.Text>>()
+    elements.filterIsInstance<ReaderElement.Text>()
+        .filter { isSearchResult(it) || isReadAloud(it) }
+        .forEach { text -> lines.getOrPut(text.bounds.top to text.bounds.bottom) { mutableListOf() } += text }
+    return lines.values.map { line ->
+        ReaderEmphasisUnderlineRun(
+            startPx = line.minOf { it.bounds.left },
+            endPx = line.maxOf { it.bounds.right },
+            yPx = line.maxOf { it.bounds.bottom } - style.bottomOffsetPx,
+            style = style,
+        )
+    }
+}
+
 @Composable
 @Composable
 private fun ReaderPageDecorationOverlay(page: ReaderPage, modifier: Modifier) {
 private fun ReaderPageDecorationOverlay(page: ReaderPage, modifier: Modifier) {
     val badge = page.decoration.bookmarkBadge
     val badge = page.decoration.bookmarkBadge

+ 5 - 11
app/src/main/java/io/legado/app/feature/reader/ScrollPageDrawCache.kt

@@ -1,13 +1,13 @@
 package io.legado.app.feature.reader
 package io.legado.app.feature.reader
 
 
-import android.graphics.Bitmap
 import android.graphics.Paint
 import android.graphics.Paint
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import io.legado.app.feature.reader.core.model.ReaderElement
 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.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
-import io.legado.app.feature.reader.core.model.emphasisUnderlineRuns
 import io.legado.app.feature.reader.core.model.textBackgroundRuns
 import io.legado.app.feature.reader.core.model.textBackgroundRuns
+import io.legado.app.feature.reader.core.style.ReaderBackgroundBand
+import io.legado.app.feature.reader.core.style.mergeBackgroundBounds
 import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
 import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
 import io.legado.app.feature.reader.platform.ReaderPageDecorationDrawCache
 import io.legado.app.feature.reader.platform.ReaderPageDecorationDrawCache
 
 
@@ -22,18 +22,12 @@ internal class ScrollPageDrawData(val page: ReaderPage) {
     val paints: Map<ReaderTextStyle, Paint> =
     val paints: Map<ReaderTextStyle, Paint> =
         textElements.map { it.style }.distinct().associateWith(ReaderAndroidPaintFactory::create)
         textElements.map { it.style }.distinct().associateWith(ReaderAndroidPaintFactory::create)
     val textBackgrounds = page.textBackgroundRuns()
     val textBackgrounds = page.textBackgroundRuns()
+    val textBackgroundBands: List<ReaderBackgroundBand> = textElements.mergeBackgroundBounds()
     val textBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
     val textBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
     val decorationDrawCache = ReaderPageDecorationDrawCache.create(page)
     val decorationDrawCache = ReaderPageDecorationDrawCache.create(page)
-    val emphasisUnderlines = page.emphasisUnderlineRuns()
 
 
-    /** 任一正文元素被宿主标为选中(搜索结果/朗读段落)时为真,供 draw 期裁剪选中背景计算。 */
-    val hasSelectedElements = textElements.any { it.selected }
-
-    /** 正文内联图片位图;加载完成写回以触发重绘。 */
-    val images = mutableStateOf<Map<ReaderElement.Image, Bitmap>>(emptyMap())
-
-    /** 文字背景位图,按 source 键;加载完成写回以触发重绘。 */
-    val textBackgroundBitmaps = mutableStateOf<Map<String, Bitmap>>(emptyMap())
+    /** Text-background bitmaps are owned by ReaderTextBackgroundLoader's byte-bounded LRU. */
+    val textBackgroundRevision = mutableStateOf(0)
 }
 }
 
 
 /**
 /**

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

@@ -1,9 +1,9 @@
 package io.legado.app.feature.reader.core.layout
 package io.legado.app.feature.reader.core.layout
 
 
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
 import io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock
-import io.legado.app.feature.reader.core.source.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
 import io.legado.app.feature.reader.core.source.ReaderInlineSourceStyle
 import io.legado.app.feature.reader.core.style.ReaderCharacterStyle
 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.ReaderCharacterStyleResolver
@@ -197,8 +197,13 @@ class ReaderChapterBlockMeasurer(
                             // subtitle bounds). Style overrides and baseline-shift spans need
                             // subtitle bounds). Style overrides and baseline-shift spans need
                             // per-glyph metrics so their visual extents can expand the shared line.
                             // per-glyph metrics so their visual extents can expand the shared line.
                             val hasBaselineShift = item.style.superscript || item.style.subscript
                             val hasBaselineShift = item.style.superscript || item.style.subscript
+                            // Highlight rules are paint-only except for an explicit size
+                            // offset.  Letting color/underline/typeface/weight matches change
+                            // the shared row metrics made line and paragraph spacing vary with
+                            // the text a rule happened to match.  HTML baseline shifts and a
+                            // requested size offset still need their own visual extents.
                             val lineMetrics = textShaper.fontLineMetrics.takeIf {
                             val lineMetrics = textShaper.fontLineMetrics.takeIf {
-                                textStyle != baseStyle || hasBaselineShift
+                                hasBaselineShift || rangeStyle?.fontSizeOffsetPx != 0f
                             }
                             }
                             val baselineShift = lineMetrics?.let { metrics ->
                             val baselineShift = lineMetrics?.let { metrics ->
                                 (if (item.style.superscript) -metrics.ascentPx / 2f else 0f) +
                                 (if (item.style.superscript) -metrics.ascentPx / 2f else 0f) +

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

@@ -164,6 +164,11 @@ data class ReaderPage(
     val decoration: ReaderPageDecoration = ReaderPageDecoration(),
     val decoration: ReaderPageDecoration = ReaderPageDecoration(),
     val inlineImagesPreserveScrollLine: Boolean = true,
     val inlineImagesPreserveScrollLine: Boolean = true,
     val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
     val emphasisUnderlineStyle: ReaderEmphasisUnderline? = null,
+    /** Dynamic search range, kept separate from immutable layout elements for draw-cache reuse. */
+    val searchStart: Int? = null,
+    val searchEndInclusive: Int? = null,
+    /** Dynamic read-aloud paragraph, likewise independent of the pagination layout. */
+    val readAloudParagraphIndex: Int? = null,
     /** 邻章未装载时预置的"加载中"占位页,分页批次落地后被同 id 真实页替换。 */
     /** 邻章未装载时预置的"加载中"占位页,分页批次落地后被同 id 真实页替换。 */
     val isPlaceholder: Boolean = false,
     val isPlaceholder: Boolean = false,
 ) {
 ) {

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

@@ -10,14 +10,22 @@ data class ReaderUnderlineRun(
 /** Coalesces adjacent styled text exactly once per visual line. */
 /** Coalesces adjacent styled text exactly once per visual line. */
 fun ReaderPage.underlineRuns(): List<ReaderUnderlineRun> {
 fun ReaderPage.underlineRuns(): List<ReaderUnderlineRun> {
     val runs = mutableListOf<ReaderUnderlineRun>()
     val runs = mutableListOf<ReaderUnderlineRun>()
+    var previousUnderlinedText: ReaderElement.Text? = null
     elements.filterIsInstance<ReaderElement.Text>().forEach { text ->
     elements.filterIsInstance<ReaderElement.Text>().forEach { text ->
-        val underline = text.style.underline ?: return@forEach
+        val underline = text.style.underline
+        if (underline == null) {
+            previousUnderlinedText = null
+            return@forEach
+        }
         val previous = runs.lastOrNull()
         val previous = runs.lastOrNull()
         if (
         if (
-            previous != null && previous.underline == underline &&
+            previous != null && previousUnderlinedText != null && previous.underline == underline &&
             abs(previous.bounds.top - text.bounds.top) < 0.5f &&
             abs(previous.bounds.top - text.bounds.top) < 0.5f &&
             abs(previous.bounds.bottom - text.bounds.bottom) < 0.5f &&
             abs(previous.bounds.bottom - text.bounds.bottom) < 0.5f &&
-            abs(previous.bounds.right - text.bounds.left) < 1f
+            // Glyph bounds intentionally exclude letter- and justification-spacing.  A
+            // continuous rule on the same visual row must span those gaps, just as the old
+            // TextLine renderer did, otherwise waves restart for every character.
+            text.bounds.left >= previous.bounds.right
         ) {
         ) {
             runs[runs.lastIndex] = previous.copy(
             runs[runs.lastIndex] = previous.copy(
                 bounds = previous.bounds.copy(right = text.bounds.right),
                 bounds = previous.bounds.copy(right = text.bounds.right),
@@ -25,6 +33,7 @@ fun ReaderPage.underlineRuns(): List<ReaderUnderlineRun> {
         } else {
         } else {
             runs += ReaderUnderlineRun(text.bounds, underline)
             runs += ReaderUnderlineRun(text.bounds, underline)
         }
         }
+        previousUnderlinedText = text
     }
     }
     return runs
     return runs
 }
 }

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

@@ -0,0 +1,65 @@
+package io.legado.app.feature.reader.core.readaloud
+
+import io.legado.app.feature.reader.core.gesture.ReaderPageViewportLayout
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderPageWindow
+
+/** A body-text anchor in the chapter coordinate space used by the read-aloud service. */
+data class ReaderVisibleTextPosition(
+    val chapterIndex: Int,
+    val chapterPosition: Int,
+)
+
+/**
+ * Resolves the first body glyph currently visible in the three-page scroll viewport.
+ *
+ * Chapter titles are represented by emphasized text and must not become the speech
+ * anchor; this keeps the Canvas reader aligned with the legacy reader's body-only
+ * read-aloud behavior.
+ */
+object ReaderVisibleTextPositionPolicy {
+    fun firstVisibleBodyText(
+        window: ReaderPageWindow,
+        scrollOffsetPx: Float,
+    ): ReaderVisibleTextPosition? {
+        val current = window.current ?: return null
+        val viewportTop = current.contentTopPx
+        val viewportBottom = current.contentBottomPx
+        return ReaderPageViewportLayout.scroll(window, scrollOffsetPx)
+            .placements
+            .asSequence()
+            .filterNot { it.page.isPlaceholder }
+            .flatMap { placement ->
+                placement.page.elements.asSequence()
+                    .filterIsInstance<ReaderElement.Text>()
+                    .filterNot { it.emphasized }
+                    .filter { text ->
+                        val top = text.bounds.top + placement.offsetY
+                        val bottom = text.bounds.bottom + placement.offsetY
+                        bottom > viewportTop && top < viewportBottom
+                    }
+                    .map { text ->
+                        VisibleText(
+                            chapterIndex = placement.page.id.chapterIndex,
+                            chapterPosition = text.chapterPosition,
+                            topPx = text.bounds.top + placement.offsetY,
+                            leftPx = text.bounds.left,
+                        )
+                    }
+            }
+            .minWithOrNull(
+                compareBy<VisibleText> { it.topPx }
+                    .thenBy { it.leftPx }
+                    .thenBy { it.chapterIndex }
+                    .thenBy { it.chapterPosition },
+            )
+            ?.let { ReaderVisibleTextPosition(it.chapterIndex, it.chapterPosition) }
+    }
+
+    private data class VisibleText(
+        val chapterIndex: Int,
+        val chapterPosition: Int,
+        val topPx: Float,
+        val leftPx: Float,
+    )
+}

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

@@ -139,7 +139,9 @@ object ReaderSelectionPolicy {
         y: Float,
         y: Float,
         locale: Locale = Locale.getDefault(),
         locale: Locale = Locale.getDefault(),
     ): ReaderSelection? {
     ): ReaderSelection? {
-        val hit = page.elementAt(x, y) as? ReaderElement.Text ?: return null
+        // Glyph bounds intentionally omit letter- and justification-spacing. Long presses in
+        // those visual gaps should start selection just like handle drags do.
+        val hit = snapToText(page, x, y) ?: return null
         val paragraph = page.elements.filterIsInstance<ReaderElement.Text>()
         val paragraph = page.elements.filterIsInstance<ReaderElement.Text>()
             .filter {
             .filter {
                 it.emphasized == hit.emphasized &&
                 it.emphasized == hit.emphasized &&

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

@@ -57,7 +57,43 @@ object ReaderCurlTouchPolicy {
         baseDurationMillis: Int = 300,
         baseDurationMillis: Int = 300,
     ): Int {
     ): Int {
         if (pageWidth <= 0f || baseDurationMillis <= 0) return 0
         if (pageWidth <= 0f || baseDurationMillis <= 0) return 0
-        return (baseDurationMillis * abs(targetX - currentX) / pageWidth).toInt()
+        val distance = abs(targetX - currentX)
+        if (distance < .01f) return 0
+        // A one- or two-pixel final travel previously rounded to 0 ms, committing the page
+        // before Compose had a chance to present the final curl frame.
+        return (baseDurationMillis * distance / pageWidth).toInt().coerceAtLeast(90)
+    }
+
+    /**
+     * Keeps the first visible curl clear of its locked corner after horizontal drag capture.
+     * This creates a small, deterministic "snap" into a drawable fold instead of allowing the
+     * Bezier controls to collapse while the finger is still on the page edge.
+     */
+    fun dragX(
+        direction: ReaderTurnDirection,
+        rawX: Float,
+        pageWidth: Float,
+        snapFraction: Float = .04f,
+    ): Float {
+        if (pageWidth <= 0f) return rawX
+        val inset = (pageWidth * snapFraction).coerceAtLeast(1f)
+        return when (direction) {
+            ReaderTurnDirection.PREVIOUS -> rawX.coerceIn(inset, pageWidth)
+            ReaderTurnDirection.NEXT -> rawX.coerceIn(0f, pageWidth - inset)
+        }
+    }
+
+    /** Interpolates the first safe fold out of its corner so the entering page does not pop in. */
+    fun revealX(
+        direction: ReaderTurnDirection,
+        targetX: Float,
+        pageWidth: Float,
+        revealProgress: Float,
+    ): Float {
+        if (pageWidth <= 0f) return targetX
+        val cornerX = if (direction == ReaderTurnDirection.NEXT) pageWidth else 0f
+        val progress = revealProgress.coerceIn(0f, 1f)
+        return cornerX + (targetX - cornerX) * progress
     }
     }
 
 
     fun programmaticX(
     fun programmaticX(

+ 7 - 3
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderStyleRangeMapper.kt

@@ -5,16 +5,16 @@ import io.legado.app.data.entities.HighlightRule
 import io.legado.app.domain.model.BookContentProcessEngine
 import io.legado.app.domain.model.BookContentProcessEngine
 import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.domain.model.TextProcessStyle
 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.ReaderTextBackgroundImage
+import io.legado.app.feature.reader.core.model.ReaderUnderline
 import io.legado.app.feature.reader.core.model.withBitmapSize
 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.ReaderChapterInlineSource
 import io.legado.app.feature.reader.core.source.ReaderChapterSource
 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.ReaderChapterSourceBlock
 import io.legado.app.feature.reader.core.style.ReaderCharacterStyle
 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.ReaderStyleRange
 import io.legado.app.feature.reader.core.style.ReaderStyleTarget
 import io.legado.app.feature.reader.core.style.ReaderStyleTarget
+import io.legado.app.feature.reader.platform.ReaderTextBackgroundLoader
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
@@ -116,7 +116,11 @@ object LegacyReaderStyleRangeMapper {
             )
             )
         },
         },
         fontPath = fontPath,
         fontPath = fontPath,
-        fontWeight = fontWeight,
+        // 400 is the persisted/default "regular" value from the View reader, where an empty
+        // font override left the body Paint untouched.  Passing it as an explicit override in
+        // the new renderer reset bold/light body text to regular.  Keep it unset so the body
+        // style remains the source of truth; non-default weights still override it.
+        fontWeight = fontWeight.takeIf { it != 400 },
         italic = isItalic,
         italic = isItalic,
         fontSizeOffsetPx = fontSizeOffset.toFloat().spToPx(),
         fontSizeOffsetPx = fontSizeOffset.toFloat().spToPx(),
         backgroundImage = bgImage?.takeIf(String::isNotBlank)?.let {
         backgroundImage = bgImage?.takeIf(String::isNotBlank)?.let {

+ 29 - 12
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -10,10 +10,18 @@ import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecord
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.entities.readRecord.ReadRecordSession
+import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.feature.reader.core.navigation.ReaderChapterPaginationSnapshot
+import io.legado.app.feature.reader.core.source.ReaderChapterSourceParser
+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.AndroidReaderHtmlSemanticTextResolver
+import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
@@ -27,7 +35,6 @@ import io.legado.app.help.book.readSimulating
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.update
 import io.legado.app.help.book.update
 import io.legado.app.help.config.ReadBookConfig
 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.coroutine.Coroutine
 import io.legado.app.help.globalExecutor
 import io.legado.app.help.globalExecutor
 import io.legado.app.model.ReadBook.callBack
 import io.legado.app.model.ReadBook.callBack
@@ -36,6 +43,8 @@ import io.legado.app.model.ReadBook.renderCallBack
 import io.legado.app.model.ReadBook.snapshot
 import io.legado.app.model.ReadBook.snapshot
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.TextFile
 import io.legado.app.model.localBook.TextFile
+import io.legado.app.model.reader.ReaderChapterInput
+import io.legado.app.model.reader.ReaderChapterInputWindow
 import io.legado.app.model.translation.TranslationChapterState
 import io.legado.app.model.translation.TranslationChapterState
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.model.translation.TranslationManager
 import io.legado.app.model.translation.TranslationManager
@@ -44,15 +53,6 @@ import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.CacheBookService
 import io.legado.app.service.CacheBookService
 import io.legado.app.ui.book.read.ConfigUpdateAction
 import io.legado.app.ui.book.read.ConfigUpdateAction
 import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.ui.book.read.ReadConfigUpdateBus
-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.ChapterContentHasher
 import io.legado.app.ui.book.read.pageestimate.ChapterLengthInfo
 import io.legado.app.ui.book.read.pageestimate.ChapterLengthInfo
 import io.legado.app.ui.book.read.pageestimate.LocalPageEstimateCalibrationStore
 import io.legado.app.ui.book.read.pageestimate.LocalPageEstimateCalibrationStore
@@ -74,7 +74,6 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.cancelChildren
 import kotlinx.coroutines.cancelChildren
-import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -89,7 +88,6 @@ import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 import org.koin.core.component.inject
 import splitties.init.appCtx
 import splitties.init.appCtx
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ConcurrentHashMap
-import kotlin.math.max
 import kotlin.math.min
 import kotlin.math.min
 
 
 
 
@@ -1291,6 +1289,25 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
     }
     }
 
 
+    /**
+     * Reader cold-start path: foreground the chapter needed for the first rendered page.
+     *
+     * Adjacent chapters preserve their normal warm-cache behavior, but beginning their content
+     * parsing only after the current chapter is available keeps them from competing with the
+     * first page's database, rule, and image work on a cold device.
+     */
+    fun loadInitialContent(
+        resetPageOffset: Boolean,
+        success: (() -> Unit)? = null,
+    ) {
+        val currentIndex = durChapterIndex
+        loadContent(currentIndex, resetPageOffset = resetPageOffset) {
+            success?.invoke()
+            loadContent(currentIndex + 1, resetPageOffset = resetPageOffset)
+            loadContent(currentIndex - 1, resetPageOffset = resetPageOffset)
+        }
+    }
+
     fun relayoutContent() {
     fun relayoutContent() {
         loadContent(
         loadContent(
             resetPageOffset = false,
             resetPageOffset = false,

+ 88 - 57
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -4,15 +4,15 @@ import android.annotation.SuppressLint
 import android.app.SearchManager
 import android.app.SearchManager
 import android.content.Intent
 import android.content.Intent
 import android.content.pm.ActivityInfo
 import android.content.pm.ActivityInfo
-import android.os.Build
 import android.graphics.drawable.Drawable
 import android.graphics.drawable.Drawable
+import android.os.Build
+import android.util.LruCache
 import android.view.Gravity
 import android.view.Gravity
 import android.view.HapticFeedbackConstants
 import android.view.HapticFeedbackConstants
 import android.view.KeyEvent
 import android.view.KeyEvent
 import android.view.View
 import android.view.View
 import android.view.WindowInsets
 import android.view.WindowInsets
 import android.view.WindowManager
 import android.view.WindowManager
-import android.util.LruCache
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.ContextCompat
 import androidx.core.content.ContextCompat
 import androidx.core.net.toUri
 import androidx.core.net.toUri
@@ -23,42 +23,41 @@ import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.BookType
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookProgress
-import io.legado.app.feature.reader.core.model.ReaderPageWindow
+import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.feature.reader.core.gesture.ReaderTapAction
 import io.legado.app.feature.reader.core.gesture.ReaderTapAction
-import io.legado.app.feature.reader.core.navigation.ReaderChapterPaginationSnapshot
-import io.legado.app.feature.reader.legacy.LegacyReaderChapterPaginator
-import io.legado.app.feature.reader.legacy.LegacyReaderChapterLayoutIdentity
-import io.legado.app.feature.reader.legacy.LegacyReaderChapterPaginationResult
-import io.legado.app.feature.reader.legacy.LegacyReaderPaginationBatch
-import io.legado.app.feature.reader.legacy.collectLegacyReaderPaginationBatch
-import io.legado.app.feature.reader.legacy.failureReasonFor
-import io.legado.app.feature.reader.legacy.paginateLegacyReaderChapterSafely
-import io.legado.app.feature.reader.legacy.LegacyReaderPageDecorationFactory
-import io.legado.app.feature.reader.legacy.LegacyReaderPaginationStyleFactory
-import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
-import io.legado.app.feature.reader.core.navigation.ReaderPageContext
-import io.legado.app.feature.reader.core.navigation.ReaderPageNavigator
 import io.legado.app.feature.reader.core.model.ReaderElement
 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.ReaderImageCachePolicy
 import io.legado.app.feature.reader.core.model.ReaderImageCachePolicy
+import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderPageId
+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.model.ReaderThemeColorChange
 import io.legado.app.feature.reader.core.model.ReaderThemeColorChange
 import io.legado.app.feature.reader.core.model.remapThemeColors
 import io.legado.app.feature.reader.core.model.remapThemeColors
-import io.legado.app.feature.reader.core.selection.ReaderSelection
-import io.legado.app.feature.reader.core.selection.ReaderSelectionMenuAnchor
+import io.legado.app.feature.reader.core.navigation.ReaderChapterPaginationSnapshot
+import io.legado.app.feature.reader.core.navigation.ReaderPageContext
+import io.legado.app.feature.reader.core.navigation.ReaderPageNavigator
+import io.legado.app.feature.reader.core.readaloud.ReaderVisibleTextPosition
 import io.legado.app.feature.reader.core.selection.ReaderSearchMatcher
 import io.legado.app.feature.reader.core.selection.ReaderSearchMatcher
 import io.legado.app.feature.reader.core.selection.ReaderSearchRequest
 import io.legado.app.feature.reader.core.selection.ReaderSearchRequest
+import io.legado.app.feature.reader.core.selection.ReaderSelection
+import io.legado.app.feature.reader.core.selection.ReaderSelectionMenuAnchor
 import io.legado.app.feature.reader.core.transition.ReaderTurnDirection
 import io.legado.app.feature.reader.core.transition.ReaderTurnDirection
-import io.legado.app.model.ImageProvider
-import io.legado.app.model.reader.ReaderChapterInput
+import io.legado.app.feature.reader.legacy.LegacyReaderChapterLayoutIdentity
+import io.legado.app.feature.reader.legacy.LegacyReaderChapterPaginator
+import io.legado.app.feature.reader.legacy.LegacyReaderPageDecorationFactory
+import io.legado.app.feature.reader.legacy.LegacyReaderPaginationBatch
+import io.legado.app.feature.reader.legacy.LegacyReaderPaginationStyleFactory
+import io.legado.app.feature.reader.legacy.collectLegacyReaderPaginationBatch
+import io.legado.app.feature.reader.legacy.failureReasonFor
+import io.legado.app.feature.reader.legacy.paginateLegacyReaderChapterSafely
+import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
 import io.legado.app.help.TTS
 import io.legado.app.help.TTS
 import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.help.storage.Backup
 import io.legado.app.help.storage.Backup
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.model.CacheBook
 import io.legado.app.model.CacheBook
+import io.legado.app.model.ImageProvider
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadSessionState
 import io.legado.app.model.ReadSessionState
@@ -66,12 +65,13 @@ import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
 import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
+import io.legado.app.model.reader.ReaderChapterInput
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.receiver.TimeBatteryReceiver
 import io.legado.app.receiver.TimeBatteryReceiver
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.BaseReadAloudService
+import io.legado.app.ui.association.OpenUrlConfirmActivity
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.login.SourceLoginJsExtensions
 import io.legado.app.ui.login.SourceLoginJsExtensions
-import io.legado.app.ui.association.OpenUrlConfirmActivity
 import io.legado.app.ui.widget.PopupAction
 import io.legado.app.ui.widget.PopupAction
 import io.legado.app.utils.ColorUtils
 import io.legado.app.utils.ColorUtils
 import io.legado.app.utils.Debounce
 import io.legado.app.utils.Debounce
@@ -79,7 +79,6 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.LogUtils
 import io.legado.app.utils.buildMainHandler
 import io.legado.app.utils.buildMainHandler
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.invisible
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.longToastOnUi
 import io.legado.app.utils.longToastOnUi
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.printOnDebug
@@ -89,18 +88,17 @@ import io.legado.app.utils.share
 import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.throttle
 import io.legado.app.utils.throttle
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.visible
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.async
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Job
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
@@ -257,10 +255,14 @@ class ReadBookController(
     private var searchSelection: ReaderSelection? = null
     private var searchSelection: ReaderSelection? = null
     private var pendingSearchNavigation: ReadBookEffect.NavigateToSearchResult? = null
     private var pendingSearchNavigation: ReadBookEffect.NavigateToSearchResult? = null
     private var readAloudPosition: Pair<Int, Int>? = null
     private var readAloudPosition: Pair<Int, Int>? = null
+    private var composeVisibleBodyTextPositionProvider: (() -> ReaderVisibleTextPosition?)? = null
     private var composeImageClickAt = 0L
     private var composeImageClickAt = 0L
     private var composeImageDoubleClick = false
     private var composeImageDoubleClick = false
     private var directReaderLayoutJob: Job? = null
     private var directReaderLayoutJob: Job? = null
     private var directReaderLayoutKey: String? = null
     private var directReaderLayoutKey: String? = null
+
+    /** Only chapter-window changes may reuse adjacent pages; a reflow invalidates their geometry. */
+    private var directReaderMayReuseAdjacentPages = false
     private var directReaderPages = emptyList<io.legado.app.feature.reader.core.model.ReaderPage>()
     private var directReaderPages = emptyList<io.legado.app.feature.reader.core.model.ReaderPage>()
 
 
     /**
     /**
@@ -284,10 +286,8 @@ class ReadBookController(
 
 
     init {
     init {
         readerSessionViewModel.submitBackground(_readerBackground.value)
         readerSessionViewModel.submitBackground(_readerBackground.value)
-        // Start image decoding before the shared-bounds destination gets its first measured frame.
-        // The measured viewport will replace this approximation if its dimensions differ.
-        val displayMetrics = activity.resources.displayMetrics
-        updateComposeReaderBackground(displayMetrics.widthPixels, displayMetrics.heightPixels)
+        // Background decoding waits for the first measured reading viewport. Decoding once with
+        // display metrics here was commonly cancelled by the real content bounds a frame later.
     }
     }
 
 
     fun dismissTextActionMenu() {
     fun dismissTextActionMenu() {
@@ -522,6 +522,9 @@ class ReadBookController(
         )
         )
         var viewportPaginationStyle: ReaderAndroidPaginationStyle? = null
         var viewportPaginationStyle: ReaderAndroidPaginationStyle? = null
         if (viewportChanged) {
         if (viewportChanged) {
+            // Width, height, density, or content insets participate in every page's geometry.
+            // Do not bridge this reflow with an adjacent page from the previous viewport.
+            directReaderMayReuseAdjacentPages = false
             updateComposeReaderBackground(widthPx, heightPx)
             updateComposeReaderBackground(widthPx, heightPx)
             val style = LegacyReaderPaginationStyleFactory.create()
             val style = LegacyReaderPaginationStyleFactory.create()
             viewportPaginationStyle = style
             viewportPaginationStyle = style
@@ -565,6 +568,7 @@ class ReadBookController(
         directReaderLayoutJob?.cancel()
         directReaderLayoutJob?.cancel()
         directReaderLayoutJob = null
         directReaderLayoutJob = null
         directReaderLayoutKey = null
         directReaderLayoutKey = null
+        directReaderMayReuseAdjacentPages = false
         ReadBook.clearReaderPagination()
         ReadBook.clearReaderPagination()
         updateReaderPaginationError(null)
         updateReaderPaginationError(null)
         publishReaderPageWindow()
         publishReaderPageWindow()
@@ -602,25 +606,15 @@ class ReadBookController(
             val pageHasSearchSelection = selection?.chapterIndex == source.id.chapterIndex
             val pageHasSearchSelection = selection?.chapterIndex == source.id.chapterIndex
             val pageHasAloudParagraph = aloudPosition?.first == source.id.chapterIndex &&
             val pageHasAloudParagraph = aloudPosition?.first == source.id.chapterIndex &&
                 aloudParagraphIndex != null
                 aloudParagraphIndex != null
-            if (!pageHasSearchSelection && !pageHasAloudParagraph) return@let decorated
-            val emphasisUnderline = source.emphasisUnderlineStyle
             decorated.copy(
             decorated.copy(
-                elements = decorated.elements.map { element ->
-                    if (element is ReaderElement.Text) {
-                        val isSearchResult = pageHasSearchSelection && selection.contains(element)
-                        val isReadAloud = pageHasAloudParagraph && !element.emphasized &&
-                            element.paragraphIndex == aloudParagraphIndex
-                        element.copy(
-                            selected = isSearchResult,
-                            searchResult = isSearchResult,
-                            readAloud = isReadAloud,
-                            emphasisUnderline = emphasisUnderline.takeIf { isSearchResult || isReadAloud },
-                        )
-                    } else element
-                },
+                // Search/read-aloud state must not clone every glyph in the visible window:
+                // scroll draw data is keyed by the immutable layout element list.  The Canvas
+                // resolves these compact dynamic ranges while drawing.
+                searchStart = selection?.anchor?.takeIf { pageHasSearchSelection },
+                searchEndInclusive = selection?.focus?.takeIf { pageHasSearchSelection },
+                readAloudParagraphIndex = aloudParagraphIndex.takeIf { pageHasAloudParagraph },
                 revision = decorated.revision xor (selection?.hashCode()?.toLong() ?: 0L) xor
                 revision = decorated.revision xor (selection?.hashCode()?.toLong() ?: 0L) xor
-                    (aloudPosition?.hashCode()?.toLong() ?: 0L) xor
-                    (emphasisUnderline?.hashCode()?.toLong() ?: 0L),
+                        (aloudPosition?.hashCode()?.toLong() ?: 0L),
             )
             )
         }
         }
         return ReaderPageWindow(
         return ReaderPageWindow(
@@ -936,11 +930,29 @@ class ReadBookController(
         }
         }
         updateReaderPaginationError(batch.failureReasonFor(currentChapter.chapter.index))
         updateReaderPaginationError(batch.failureReasonFor(currentChapter.chapter.index))
         val previousPages = directReaderPages.associateBy { it.id }
         val previousPages = directReaderPages.associateBy { it.id }
-        directReaderPages = batch.pages.takeIf { batch.hasCurrentChapter }.orEmpty().map { page ->
+        val replacementChapterIndexes = batch.pages.mapTo(mutableSetOf()) { it.id.chapterIndex }
+        val retainedPages = if (directReaderMayReuseAdjacentPages) {
+            directReaderPages.filterNot { it.id.chapterIndex in replacementChapterIndexes }
+        } else {
+            emptyList()
+        }
+        val replacementPages =
+            batch.pages.takeIf { batch.hasCurrentChapter }.orEmpty().map { page ->
             previousPages[page.id]?.takeIf(page::hasSameGeometryAs)?.let { previous ->
             previousPages[page.id]?.takeIf(page::hasSameGeometryAs)?.let { previous ->
                 page.copy(layoutRevision = previous.layoutRevision)
                 page.copy(layoutRevision = previous.layoutRevision)
             } ?: page
             } ?: page
-        }.sortedWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
+            }
+        // Pagination deliberately publishes the current chapter before the adjacent chapters.
+        // Keep an already shaped adjacent page during that first batch: replacing the whole
+        // window here made every chapter turn recreate a "loading" placeholder even when the
+        // target page was still valid in memory.
+        directReaderPages = (retainedPages + replacementPages)
+            .sortedWith(compareBy({ it.id.chapterIndex }, { it.id.pageIndex }))
+        if (layoutComplete) {
+            // A complete batch establishes one shared pagination environment. Subsequent
+            // chapter-window changes may retain its already-shaped adjacent pages.
+            directReaderMayReuseAdjacentPages = true
+        }
         // 重排可能改变元素位置与页 endPosition,页上下文缓存全部失效。
         // 重排可能改变元素位置与页 endPosition,页上下文缓存全部失效。
         directReaderPageContexts.clear()
         directReaderPageContexts.clear()
         directReaderChapterPageCounts = directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
         directReaderChapterPageCounts = directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
@@ -1768,6 +1780,24 @@ class ReadBookController(
         directReaderPageIndex?.let(::publishDirectReaderWindow)
         directReaderPageIndex?.let(::publishDirectReaderWindow)
     }
     }
 
 
+    fun setComposeVisibleBodyTextPositionProvider(
+        provider: (() -> ReaderVisibleTextPosition?)?,
+    ) {
+        composeVisibleBodyTextPositionProvider = provider
+    }
+
+    private fun readAloudFromComposeVisibleStart(): Boolean {
+        val position = composeVisibleBodyTextPositionProvider?.invoke() ?: return false
+        // The Canvas window is normally kept on the logical current page. A crossing may
+        // briefly expose a neighbor before its chapter becomes the active ReadBook chapter;
+        // let the existing chapter-transition path handle that case rather than speaking
+        // with mismatched chapter coordinates.
+        if (position.chapterIndex != ReadBook.durChapterIndex) return false
+        ReadBook.updateReadingPosition(position.chapterPosition)
+        ReadBook.readAloud(chapterPosition = position.chapterPosition)
+        return true
+    }
+
     // ── Key handling ──
     // ── Key handling ──
 
 
     private fun toggleReadAloud() {
     private fun toggleReadAloud() {
@@ -1775,12 +1805,13 @@ class ReadBookController(
         when {
         when {
             !BaseReadAloudService.isRun -> {
             !BaseReadAloudService.isRun -> {
                 ReadAloud.upReadAloudClass()
                 ReadAloud.upReadAloudClass()
-                ReadBook.readAloud()
+                if (!readAloudFromComposeVisibleStart()) ReadBook.readAloud()
             }
             }
 
 
             BaseReadAloudService.pause -> {
             BaseReadAloudService.pause -> {
+                val restartFromVisibleStart = pageChanged && readAloudFromComposeVisibleStart()
                 pageChanged = false
                 pageChanged = false
-                ReadAloud.resume(activity)
+                if (!restartFromVisibleStart) ReadAloud.resume(activity)
             }
             }
 
 
             else -> ReadAloud.pause(activity)
             else -> ReadAloud.pause(activity)

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

@@ -164,7 +164,7 @@ class ReadBookLoadDelegate(
         host.checkReadRecordAlias(book)
         host.checkReadRecordAlias(book)
 
 
         if (!isSameBook) {
         if (!isSameBook) {
-            ReadBook.loadContent(resetPageOffset = true) {
+            ReadBook.loadInitialContent(resetPageOffset = true) {
                 ReadBook.bookSource?.let {
                 ReadBook.bookSource?.let {
                     SourceCallBack.callBackBook(
                     SourceCallBack.callBackBook(
                         SourceCallBack.START_READ,
                         SourceCallBack.START_READ,

+ 14 - 13
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -10,8 +10,8 @@ import android.os.SystemClock
 import android.view.KeyEvent
 import android.view.KeyEvent
 import android.view.View
 import android.view.View
 import android.view.ViewTreeObserver
 import android.view.ViewTreeObserver
-import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.AnimatedVisibility
@@ -26,9 +26,9 @@ import androidx.compose.animation.fadeOut
 import androidx.compose.animation.scaleIn
 import androidx.compose.animation.scaleIn
 import androidx.compose.animation.scaleOut
 import androidx.compose.animation.scaleOut
 import androidx.compose.foundation.background
 import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.clickable
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.DisposableEffect
@@ -39,8 +39,8 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.withFrameNanos
 import androidx.compose.runtime.withFrameNanos
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalConfiguration
@@ -67,8 +67,8 @@ import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
 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.ReaderBackgroundSurface
+import io.legado.app.feature.reader.ReaderCanvasSurface
 import io.legado.app.feature.reader.core.gesture.ReaderTapActionGrid
 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.model.readerBackgroundAlpha
 import io.legado.app.feature.reader.core.transition.ReaderTransitionMode
 import io.legado.app.feature.reader.core.transition.ReaderTransitionMode
@@ -88,10 +88,10 @@ import io.legado.app.ui.main.AndroidPlatformCapabilities
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 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.theme.LegadoTheme
-import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.widget.components.image.cover.sharedCoverSourceRadius
 import io.legado.app.ui.widget.components.image.cover.sharedCoverSourceRadius
+import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.takePersistablePermissionSafely
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
@@ -656,13 +656,13 @@ fun ReadBookRouteScreen(
             Modifier
             Modifier
                 .fillMaxSize()
                 .fillMaxSize()
                 .onSizeChanged { size ->
                 .onSizeChanged { size ->
-                controller.onComposeReaderViewportChanged(
-                    widthPx = size.width,
-                    heightPx = size.height,
-                    density = density,
-                    contentPadding = readerContentPadding,
-                )
-            }
+                    controller.onComposeReaderViewportChanged(
+                        widthPx = size.width,
+                        heightPx = size.height,
+                        density = density,
+                        contentPadding = readerContentPadding,
+                    )
+                }
         ) {
         ) {
             ReaderBackgroundSurface(
             ReaderBackgroundSurface(
                 backgroundImage = readerBackground.drawable,
                 backgroundImage = readerBackground.drawable,
@@ -731,6 +731,7 @@ fun ReadBookRouteScreen(
                 noAnimationScrollPage = readPreferences.noAnimScrollPage,
                 noAnimationScrollPage = readPreferences.noAnimScrollPage,
                 externalPageTurns = controller.composePageTurns,
                 externalPageTurns = controller.composePageTurns,
                 externalSelectionCancels = controller.composeSelectionCancels,
                 externalSelectionCancels = controller.composeSelectionCancels,
+                    onVisibleBodyTextPositionProvider = controller::setComposeVisibleBodyTextPositionProvider,
                 )
                 )
             }
             }
             AnimatedVisibility(
             AnimatedVisibility(

+ 4 - 3
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -50,7 +50,6 @@ import io.legado.app.ui.book.audio.AudioPlayIntent
 import io.legado.app.ui.book.audio.AudioPlayScreenContent
 import io.legado.app.ui.book.audio.AudioPlayScreenContent
 import io.legado.app.ui.book.audio.AudioPlayViewModel
 import io.legado.app.ui.book.audio.AudioPlayViewModel
 import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
 import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
-import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.book.explore.ExploreShowIntent
 import io.legado.app.ui.book.explore.ExploreShowIntent
 import io.legado.app.ui.book.explore.ExploreShowRouteScreen
 import io.legado.app.ui.book.explore.ExploreShowRouteScreen
 import io.legado.app.ui.book.explore.ExploreShowViewModel
 import io.legado.app.ui.book.explore.ExploreShowViewModel
@@ -142,9 +141,9 @@ import io.legado.app.ui.rss.subscription.RuleSubRouteScreen
 import io.legado.app.ui.theme.ProvideThemeOverride
 import io.legado.app.ui.theme.ProvideThemeOverride
 import io.legado.app.ui.theme.rememberImageSeedColor
 import io.legado.app.ui.theme.rememberImageSeedColor
 import io.legado.app.ui.theme.rememberThemeOverride
 import io.legado.app.ui.theme.rememberThemeOverride
+import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.sendToClip
 import io.legado.app.utils.sendToClip
-import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.startActivityForBook
 import io.legado.app.utils.startActivityForBook
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.toggleSystemBar
@@ -725,9 +724,11 @@ fun MainActivity.mainEntryProvider(
         }
         }
 
 
         LaunchedEffect(route, readBookViewModel, lifecycleOwner) {
         LaunchedEffect(route, readBookViewModel, lifecycleOwner) {
+            // Resolving the book and applying its read style do not depend on launcher effects.
+            // Start that I/O immediately; initData still waits below because it can emit effects.
+            val initialBook = readBookViewModel.initReadBookConfig(initRequest)
             effectsReady.await()
             effectsReady.await()
             collectorReady[0] = true
             collectorReady[0] = true
-            val initialBook = readBookViewModel.initReadBookConfig(initRequest)
             readBookViewModel.initData(initRequest, initialBook) {
             readBookViewModel.initData(initRequest, initialBook) {
                 readBookViewModel.markJustInitData()
                 readBookViewModel.markJustInitData()
                 controller.onRouteInitialized()
                 controller.onRouteInitialized()

+ 15 - 0
app/src/test/java/io/legado/app/feature/reader/ScrollPageDrawCacheTest.kt

@@ -31,6 +31,21 @@ class ScrollPageDrawCacheTest {
         assertSame(originalData, cache.ensure(refreshed))
         assertSame(originalData, cache.ensure(refreshed))
     }
     }
 
 
+    @Test
+    fun `read aloud and search refresh reuse immutable layout draw data`() {
+        val original = page(listOf(text()))
+        val highlighted = original.copy(
+            searchStart = 0,
+            searchEndInclusive = 3,
+            readAloudParagraphIndex = 0,
+        )
+        val cache = ScrollPageDrawCache()
+
+        val originalData = cache.ensure(original)
+
+        assertSame(originalData, cache.ensure(highlighted))
+    }
+
     @Test
     @Test
     fun `repaginated page with a new elements list rebuilds draw data`() {
     fun `repaginated page with a new elements list rebuilds draw data`() {
         val original = page(listOf(text()))
         val original = page(listOf(text()))

+ 27 - 2
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderUnderlineRunTest.kt

@@ -18,7 +18,7 @@ class ReaderUnderlineRunTest {
     }
     }
 
 
     @Test
     @Test
-    fun `line style and geometry changes start distinct segments`() {
+    fun `line and style changes start distinct segments`() {
         val other = style.copy(underline = underline.copy(mode = 3))
         val other = style.copy(underline = underline.copy(mode = 3))
         val page = page(
         val page = page(
             text(0f, 0f, 10f, 20f, style),
             text(0f, 0f, 10f, 20f, style),
@@ -27,7 +27,32 @@ class ReaderUnderlineRunTest {
             text(10f, 20f, 20f, 40f, other),
             text(10f, 20f, 20f, 40f, other),
         )
         )
 
 
-        assertEquals(4, page.underlineRuns().size)
+        assertEquals(3, page.underlineRuns().size)
+    }
+
+    @Test
+    fun `letter spacing remains one continuous underline run`() {
+        val page = page(
+            text(0f, 0f, 10f, 20f, style),
+            text(14f, 0f, 24f, 20f, style),
+        )
+
+        assertEquals(listOf(ReaderRect(0f, 0f, 24f, 20f)), page.underlineRuns().map { it.bounds })
+    }
+
+    @Test
+    fun `unstyled text breaks an underline run`() {
+        val plain = style.copy(underline = null)
+        val page = page(
+            text(0f, 0f, 10f, 20f, style),
+            text(10f, 0f, 20f, 20f, plain),
+            text(20f, 0f, 30f, 20f, style),
+        )
+
+        assertEquals(
+            listOf(ReaderRect(0f, 0f, 10f, 20f), ReaderRect(20f, 0f, 30f, 20f)),
+            page.underlineRuns().map { it.bounds },
+        )
     }
     }
 
 
     private fun text(left: Float, top: Float, right: Float, bottom: Float, textStyle: ReaderTextStyle) =
     private fun text(left: Float, top: Float, right: Float, bottom: Float, textStyle: ReaderTextStyle) =

+ 92 - 0
app/src/test/java/io/legado/app/feature/reader/core/readaloud/ReaderVisibleTextPositionPolicyTest.kt

@@ -0,0 +1,92 @@
+package io.legado.app.feature.reader.core.readaloud
+
+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.ReaderPageId
+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.model.ReaderTextStyle
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ReaderVisibleTextPositionPolicyTest {
+    private val style = ReaderTextStyle(0xff000000.toInt(), 16f)
+
+    @Test
+    fun anchorsReadAloudAtTheFirstVisibleBodyGlyph() {
+        val page = page(
+            chapter = 2,
+            index = 3,
+            elements = listOf(
+                text("标题", 0, 0f, emphasized = true),
+                text("甲", 2, 20f),
+                text("乙", 3, 50f),
+            ),
+        )
+
+        assertEquals(
+            ReaderVisibleTextPosition(chapterIndex = 2, chapterPosition = 3),
+            ReaderVisibleTextPositionPolicy.firstVisibleBodyText(
+                ReaderPageWindow(current = page),
+                scrollOffsetPx = -45f,
+            ),
+        )
+    }
+
+    @Test
+    fun resolvesTheVisiblePreviousPageBeforeTheCurrentPage() {
+        val previous = page(1, 5, listOf(text("前", 40, 80f)))
+        val current = page(2, 0, listOf(text("当", 0, 10f)))
+
+        assertEquals(
+            ReaderVisibleTextPosition(chapterIndex = 1, chapterPosition = 40),
+            ReaderVisibleTextPositionPolicy.firstVisibleBodyText(
+                ReaderPageWindow(previous = previous, current = current),
+                scrollOffsetPx = 20f,
+            ),
+        )
+    }
+
+    @Test
+    fun ignoresPlaceholderAndTitleOnlyPages() {
+        val placeholder = page(1, 0, listOf(text("加载中", 0, 0f)), placeholder = true)
+        val titleOnly = page(2, 0, listOf(text("标题", 0, 0f, emphasized = true)))
+
+        assertNull(
+            ReaderVisibleTextPositionPolicy.firstVisibleBodyText(
+                ReaderPageWindow(previous = placeholder, current = titleOnly),
+                scrollOffsetPx = 0f,
+            ),
+        )
+    }
+
+    private fun page(
+        chapter: Int,
+        index: Int,
+        elements: List<ReaderElement.Text>,
+        placeholder: Boolean = false,
+    ) = ReaderPage(
+        id = ReaderPageId(chapter, index),
+        chapterTitle = "",
+        text = "",
+        widthPx = 100,
+        heightPx = 100,
+        contentTopPx = 0f,
+        contentBottomPx = 100f,
+        elements = elements,
+        revision = 1L,
+        isPlaceholder = placeholder,
+    )
+
+    private fun text(value: String, position: Int, top: Float, emphasized: Boolean = false) =
+        ReaderElement.Text(
+            bounds = ReaderRect(0f, top, 20f, top + 20f),
+            baselinePx = top + 15f,
+            value = value,
+            style = style,
+            selected = false,
+            emphasized = emphasized,
+            chapterPosition = position,
+        )
+}

+ 26 - 1
app/src/test/java/io/legado/app/feature/reader/core/selection/ReaderSelectionTest.kt

@@ -1,6 +1,10 @@
 package io.legado.app.feature.reader.core.selection
 package io.legado.app.feature.reader.core.selection
 
 
-import io.legado.app.feature.reader.core.model.*
+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.ReaderPageId
+import io.legado.app.feature.reader.core.model.ReaderRect
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertNull
 import org.junit.Assert.assertNull
 import org.junit.Test
 import org.junit.Test
@@ -92,6 +96,27 @@ class ReaderSelectionTest {
         assertEquals("word", selection.selectedText(paragraphs))
         assertEquals("word", selection.selectedText(paragraphs))
     }
     }
 
 
+    @Test
+    fun longPressSnapsAcrossLetterSpacingGaps() {
+        val spaced = page.copy(
+            elements = listOf(
+                ReaderElement.Text(
+                    ReaderRect(0f, 0f, 10f, 20f), 15f, "甲", style, false, false,
+                    chapterPosition = 0,
+                ),
+                ReaderElement.Text(
+                    ReaderRect(14f, 0f, 24f, 20f), 15f, "乙", style, false, false,
+                    chapterPosition = 1,
+                ),
+            )
+        )
+
+        val selection = ReaderSelectionPolicy.startWord(spaced, 12f, 10f, Locale.CHINESE)
+
+        assertEquals(0, selection?.anchor)
+        assertEquals("甲乙", selection?.selectedText(spaced))
+    }
+
     @Test fun handlesKeepSemanticStartWhenSelectionIsReversed() {
     @Test fun handlesKeepSemanticStartWhenSelectionIsReversed() {
         val reversed = ReaderSelection(0, 2, 0)
         val reversed = ReaderSelection(0, 2, 0)
         assertEquals(1, reversed.moveStart(1).start)
         assertEquals(1, reversed.moveStart(1).start)

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

@@ -86,6 +86,40 @@ class PageCurlGeometryTest {
         assertEquals(0, ReaderCurlTouchPolicy.settleDurationMillis(1000f, 1000f, 1000f))
         assertEquals(0, ReaderCurlTouchPolicy.settleDurationMillis(1000f, 1000f, 1000f))
     }
     }
 
 
+    @Test
+    fun simulationCurlSnapsAwayFromTheDegenerateCornerAndKeepsAVisibleFinalFrame() {
+        assertEquals(960f, ReaderCurlTouchPolicy.dragX(ReaderTurnDirection.NEXT, 999f, 1000f))
+        assertEquals(40f, ReaderCurlTouchPolicy.dragX(ReaderTurnDirection.PREVIOUS, 1f, 1000f))
+        assertEquals(90, ReaderCurlTouchPolicy.settleDurationMillis(999f, 1000f, 1000f))
+    }
+
+    @Test
+    fun simulationCurlRevealsTheSafeSnapContinuouslyFromTheTouchedCorner() {
+        assertEquals(
+            1000f,
+            ReaderCurlTouchPolicy.revealX(ReaderTurnDirection.NEXT, 960f, 1000f, 0f)
+        )
+        assertEquals(
+            980f,
+            ReaderCurlTouchPolicy.revealX(ReaderTurnDirection.NEXT, 960f, 1000f, .5f)
+        )
+        assertEquals(
+            40f,
+            ReaderCurlTouchPolicy.revealX(ReaderTurnDirection.PREVIOUS, 80f, 1000f, .5f)
+        )
+    }
+
+    @Test
+    fun cancelledPreviousCurlReturnsPastTheLeftEdge() {
+        assertEquals(
+            -1000f, ReaderCurlTouchPolicy.settledX(
+                ReaderTurnDirection.PREVIOUS,
+                committed = false,
+                pageWidth = 1000f,
+            )
+        )
+    }
+
     @Test fun simulationCornerStaysLockedWhileTouchCrossesTheViewportMidpoint() {
     @Test fun simulationCornerStaysLockedWhileTouchCrossesTheViewportMidpoint() {
         val corner = CurlPoint(1080f, 0f)
         val corner = CurlPoint(1080f, 0f)
         val first = PageCurlGeometry.calculate(1080f, 1920f, 900f, 300f, corner)!!
         val first = PageCurlGeometry.calculate(1080f, 1920f, 900f, 300f, corner)!!