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

feat: 阅读器相关优化与修复

HapeLee 2 месяцев назад
Родитель
Сommit
2af6529592

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

@@ -412,16 +412,6 @@ object ReaderPaginator {
                     is ReaderMeasuredInlineItem.Image -> "\uFFFC"
                 }
             }
-            fun backgroundImage(index: Int) = (paragraph.items[index] as? ReaderMeasuredInlineItem.Text)
-                ?.style?.backgroundImage?.takeIf { it.fit == 3 }
-            fun backgroundInsetBefore(index: Int, lineStart: Int): Float {
-                val image = backgroundImage(index) ?: return 0f
-                return if (index == lineStart || backgroundImage(index - 1) != image) image.contentInsetLeftPx else 0f
-            }
-            fun backgroundInsetAfter(index: Int, lineEnd: Int): Float {
-                val image = backgroundImage(index) ?: return 0f
-                return if (index + 1 == lineEnd || backgroundImage(index + 1) != image) image.contentInsetRightPx else 0f
-            }
             val breaker = ChineseLineBreaker(
                 clusters = clusters,
                 widthsPx = paragraph.items.map { it.widthPx + letterSpacing },
@@ -433,34 +423,25 @@ object ReaderPaginator {
                 firstLineWidthPx = (config.contentWidthPx - indentWidth)
                     .coerceAtLeast(0f).toInt(),
             )
-            val originalEnds = breaker.lineClusterStarts.drop(1)
-            val starts = mutableListOf(0)
-            // ZhLayout reserves the nine-slice side pieces after shaping, then pulls an
-            // overflowing line back into the column. Move whole clusters instead so Canvas
-            // keeps stable hit-test and selection geometry.
-            while (starts.last() < paragraph.items.size) {
-                val from = starts.last()
-                val lineIndent = if (starts.size == 1) indentWidth else paragraph.restLineIndentWidthPx
-                val available = config.contentWidthPx - lineIndent
-                var until = originalEnds.firstOrNull { it > from } ?: paragraph.items.size
-                fun occupiedWidth(until: Int): Float = (from until until).sumOf { index ->
-                    (paragraph.items[index].widthPx + backgroundInsetBefore(index, from) +
-                        backgroundInsetAfter(index, until)).toDouble()
-                }.toFloat() + letterSpacing * (until - from - 1).coerceAtLeast(0)
-                while (until - from > 1 && occupiedWidth(until) > available) {
-                    val candidate = until - 1
-                    if (ChineseLineBreaker.isForbiddenBreak(clusters[candidate - 1], clusters[candidate])) break
-                    until = candidate
-                }
-                starts += until
-            }
+            // Nine-slice edges are a paint-time frame around a matched run.  They must not
+            // take width away from the text line: doing so made large left/right slices create
+            // artificial one-character lines and inflated justification gaps.  Keep the same
+            // text shaping boundary as the View reader, then expand only the drawn background.
+            val starts = breaker.lineClusterStarts
             for (lineIndex in 0 until starts.lastIndex) {
                 val from = starts[lineIndex]
                 val until = starts[lineIndex + 1]
                 val lineItems = paragraph.items.subList(from, until)
                 val textItems = lineItems.filterIsInstance<ReaderMeasuredInlineItem.Text>()
-                val maxTextScale = textItems
-                    .maxOfOrNull { it.style.fontSizePx / paragraph.baseTextSizePx.coerceAtLeast(1f) } ?: 1f
+                // Inline HTML may shrink every glyph in a row (<small>, font-size, etc.).
+                // It changes glyph drawing but not the paragraph's base line box; otherwise a
+                // small final row advances less and makes the following paragraph gap collapse.
+                val maxTextScale = maxOf(
+                    1f,
+                    textItems.maxOfOrNull {
+                        it.style.fontSizePx / paragraph.baseTextSizePx.coerceAtLeast(1f)
+                    } ?: 1f,
+                )
                 val fallbackLineHeight = paragraph.lineHeightPx * maxTextScale
                 val fallbackBaseline = paragraph.baselineOffsetPx * maxTextScale
                 // Keep the paragraph's unshifted line box as the minimum. A line containing
@@ -483,11 +464,8 @@ object ReaderPaginator {
                 if (y + actualLineHeight > config.contentBottomPx && columnHasContent()) advanceColumn()
                 val indent = if (lineIndex == 0) indentWidth else paragraph.restLineIndentWidthPx
                 val available = (config.contentWidthPx - indent).coerceAtLeast(0f)
-                fun backgroundInsetBefore(index: Int) = backgroundInsetBefore(from + index, from)
-                fun backgroundInsetAfter(index: Int) = backgroundInsetAfter(from + index, until)
                 val naturalWidth = lineItems.sumOf { it.widthPx.toDouble() }.toFloat() +
-                    letterSpacing * (lineItems.size - 1).coerceAtLeast(0) +
-                    lineItems.indices.sumOf { (backgroundInsetBefore(it) + backgroundInsetAfter(it)).toDouble() }.toFloat()
+                        letterSpacing * (lineItems.size - 1).coerceAtLeast(0)
                 val indentItems = (paragraph.leadingIndentItems - from).coerceIn(0, lineItems.size)
                 val stretchableGaps = (lineItems.size - indentItems - 1).coerceAtLeast(0)
                 val shouldJustify =
@@ -540,7 +518,6 @@ object ReaderPaginator {
                     }
                 }
                 lineItems.forEachIndexed { itemIndex, item ->
-                    x += backgroundInsetBefore(itemIndex)
                     val itemBackground = (item as? ReaderMeasuredInlineItem.Text)
                         ?.style?.backgroundImage
                     when (item) {
@@ -589,7 +566,7 @@ object ReaderPaginator {
                         }
                     }
                     pageText.append(if (item is ReaderMeasuredInlineItem.Text) item.value else '\uFFFC')
-                    x += item.widthPx + backgroundInsetAfter(itemIndex) + letterSpacing +
+                    x += item.widthPx + letterSpacing +
                         if (item is ReaderMeasuredInlineItem.Text && item.value == " ") wordSpaceExtra else 0f
                     x += if (itemIndex >= indentItems) justifyGap else 0f
                 }

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

@@ -5,6 +5,8 @@ data class ReaderSearchRequest(
     val directLength: Int,
     val occurrence: Int,
     val isRegex: Boolean,
+    /** Characters present in the search document but excluded from the renderer's body text. */
+    val leadingCharactersExcludedFromContent: Int = 0,
 )
 
 data class ReaderSearchMatch(val start: Int, val length: Int)
@@ -14,7 +16,7 @@ object ReaderSearchMatcher {
     fun find(content: String, query: String, request: ReaderSearchRequest): ReaderSearchMatch? {
         if (query.isEmpty()) return null
         val directLength = request.directLength.takeIf { it > 0 } ?: query.length
-        val directIndex = request.directIndex
+        val directIndex = request.directIndex - request.leadingCharactersExcludedFromContent
         if (directIndex >= 0 && directIndex + directLength <= content.length) {
             val matches = if (request.isRegex) {
                 runCatching {

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

@@ -723,8 +723,14 @@ class ReadBookController(
     ) {
         val result = navigation.result
         val query = result.query.ifBlank { viewModel.uiState.value.searchContentQuery }
-        // 选区锚点、updateReadingPosition、locate 都以 semanticContent 为字符空间,
-        // 检索仓库的 queryIndexInChapter 在含标题/图片章节会偏移,交由 matcher 校验失败后按 occurrence 回退
+        // 选区锚点、updateReadingPosition、locate 都以 semanticContent 为字符空间。
+        // 检索结果把显示标题也算入章节字符串,Canvas 的 semanticContent 则只保存正文;
+        // 不先扣掉标题前缀,偏移位置若恰好也是同一关键词会被当成有效直达结果,导致高亮落在别处。
+        val titlePrefixLength = input.source.blocks
+            .any { it is io.legado.app.feature.reader.core.source.ReaderChapterSourceBlock.Text && it.isTitle }
+            .takeIf { it }
+            ?.let { input.source.title.length + 1 }
+            ?: 0
         val match = ReaderSearchMatcher.find(
             content = input.source.semanticContent,
             query = query,
@@ -733,6 +739,7 @@ class ReadBookController(
                 directLength = result.matchLength,
                 occurrence = result.resultCountWithinChapter,
                 isRegex = result.isRegex,
+                leadingCharactersExcludedFromContent = titlePrefixLength,
             ),
         ) ?: run {
             pendingSearchNavigation = null
@@ -848,6 +855,21 @@ class ReadBookController(
             publishDirectReaderWindow(index)
             return true
         }
+        // A neighboring chapter may already have a complete page set from the preceding
+        // window. Publish it immediately while the new three-chapter batch is shaped; the
+        // View reader keeps that warm page visible instead of flashing a loading surface on a
+        // normal cached chapter turn. A later batch still replaces it if its identity changed.
+        directReaderPages
+            .takeIf { pages -> pages.any { it.id.chapterIndex == chapter.chapter.index && !it.isPlaceholder } }
+            ?.let { pages ->
+                publishDirectReaderWindow(
+                    ReaderPageNavigator.locate(
+                        pages,
+                        chapter.chapter.index,
+                        ReadBook.durChapterPos,
+                    )
+                )
+            }
         if (directReaderLayoutKey != key) {
             val paginationGeneration = ReadBook.readerPaginationGeneration
             directReaderLayoutJob?.cancel()
@@ -2026,6 +2048,27 @@ class ReadBookController(
             ReadBook.moveToPrevChapter(upContent = false, toLast = true, upContentInPlace = false)
         }
         if (!moved) return null
+        // moveToNext/PrevChapter promotes a warm ReaderChapterInput without asking the
+        // renderer to redraw. When its Canvas pages are already cached, use them directly.
+        // Previously this path always appended a placeholder, causing a visible "loading"
+        // flash even though the next/previous chapter could be rendered immediately.
+        directReaderPages
+            .indexOfFirst { page ->
+                page.id.chapterIndex == targetChapterIndex && !page.isPlaceholder
+            }
+            .takeIf { it >= 0 }
+            ?.let {
+                val targetIndex = ReaderPageNavigator.locate(
+                    directReaderPages,
+                    targetChapterIndex,
+                    ReadBook.durChapterPos,
+                )
+                directReaderPageIndex = targetIndex
+                val window = publishDirectReaderWindow(targetIndex)
+                pageChanged = true
+                viewModel.startBackupJob()
+                return window
+            }
         val placeholder = placeholderReaderPage(targetChapterIndex) ?: return null
         val pages = directReaderPages.toMutableList()
         pages.add(placeholder)

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

@@ -735,15 +735,15 @@ fun ReadBookRouteScreen(
                 )
             }
             AnimatedVisibility(
-                visible = readerEntranceSettled && !hasReadablePage,
+                // Generic "loading data" duplicated the Canvas placeholder and could flash
+                // before a warm cached chapter page was republished. The body renderer owns
+                // normal loading feedback; this outer layer is reserved for messages/errors.
+                visible = readerEntranceSettled && !hasReadablePage &&
+                        (state.msg != null || readerPaginationError != null),
                 enter = fadeIn(animationSpec = tween(300)),
                 exit = fadeOut(animationSpec = tween(300)),
             ) {
-                val message = state.msg ?: if (readerPaginationError != null) {
-                    stringResource(R.string.load_error_retry)
-                } else {
-                    stringResource(R.string.data_loading)
-                }
+                val message = state.msg ?: stringResource(R.string.load_error_retry)
                 val retryable = state.msg == null && readerPaginationError != null
                 val retryLabel = stringResource(R.string.dynamic_click_retry)
                 Box(

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

@@ -28,8 +28,12 @@ fun ReadBookSearchBar(
     state: ReadBookUiState,
     onIntent: (ReadBookIntent) -> Unit,
 ) {
+    // The side result controls belong to the search menu, not merely to search mode.
+    // Keeping them tied to searchMenuVisible prevents them from lingering after the menu is
+    // dismissed and mirrors ReadBookMenuBar's visibility contract.
     val searchVisible = state.isShowingSearchResult &&
-            !(state.menuVisible && state.menuState.currentRoute != ReadBookMenuRoute.Main)
+            state.searchMenuVisible &&
+            !state.menuVisible
     val hasResults = state.searchResultList.isNotEmpty()
     val totalResults = state.searchResultList.size
     val currentIndex = state.searchResultIndex.coerceIn(0, (totalResults - 1).coerceAtLeast(0))

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

@@ -41,6 +41,22 @@ class ReaderLineSpacingTest {
         assertEquals(listOf(0f, 35f), page.elements.map { it.bounds.top })
     }
 
+    @Test
+    fun smallerInlineTextKeepsTheBodyLineBoxAndFollowingParagraphGap() {
+        val small = style.copy(fontSizePx = 10f)
+        val first = inline("甲", multiplier = 1.5f).copy(
+            items = listOf(ReaderMeasuredInlineItem.Text("甲", 10f, small, 0)),
+        )
+        val page = ReaderPaginator.paginateBlocks(
+            listOf(first, inline("乙")),
+            config.copy(viewportHeightPx = 100, paragraphSpacingPx = 5f),
+        ).single()
+
+        val text = page.elements.filterIsInstance<ReaderElement.Text>()
+        assertEquals(20f, text.first().bounds.height, 0f)
+        assertEquals(listOf(0f, 35f), text.map { it.bounds.top })
+    }
+
     @Test fun doubledFontAndInlineImageDetermineHeightBeforeSpacingMultiplier() {
         val paragraph = inline("甲乙").copy(items = listOf(
             ReaderMeasuredInlineItem.Text("甲", 20f, style.copy(fontSizePx = 40f), 0),

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

@@ -433,7 +433,7 @@ class ReaderPaginatorTest {
     }
 
     @Test
-    fun nineSliceSidePiecesReserveSpaceAndReflowEachVisualLine() {
+    fun nineSliceSidePiecesDoNotChangeTextWrappingOrPlacement() {
         val frame = ReaderTextBackgroundImage(
             source = "frame.png",
             fit = 3,
@@ -457,13 +457,16 @@ class ReaderPaginatorTest {
         ).single()
 
         val glyphs = page.elements.filterIsInstance<ReaderElement.Text>()
-        assertEquals(listOf(3f, 3f, 3f, 3f), glyphs.map { it.bounds.left })
-        assertEquals(listOf(0f, 20f, 40f, 60f), glyphs.map { it.bounds.top })
-        assertTrue(page.textBackgroundRuns().all { it.bounds.left == 0f && it.bounds.right == 17f })
+        assertEquals(listOf(0f, 10f, 0f, 10f), glyphs.map { it.bounds.left })
+        assertEquals(listOf(0f, 0f, 20f, 20f), glyphs.map { it.bounds.top })
+        assertEquals(
+            listOf(-3f to 24f, -3f to 24f),
+            page.textBackgroundRuns().map { it.bounds.left to it.bounds.right },
+        )
     }
 
     @Test
-    fun nineSliceReflowDoesNotOrphanClosingPunctuation() {
+    fun nineSliceDoesNotOrphanClosingPunctuation() {
         val framedStyle = style.copy(backgroundImage = ReaderTextBackgroundImage(
             "frame.png", 3, 1f, contentInsetLeftPx = 3f, contentInsetRightPx = 4f,
         ))

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

@@ -10,7 +10,14 @@ class ReaderSearchMatcherTest {
         occurrence: Int = 0,
         directLength: Int = 0,
         isRegex: Boolean = false,
-    ) = ReaderSearchRequest(directIndex, directLength, occurrence, isRegex)
+        leadingCharactersExcludedFromContent: Int = 0,
+    ) = ReaderSearchRequest(
+        directIndex,
+        directLength,
+        occurrence,
+        isRegex,
+        leadingCharactersExcludedFromContent,
+    )
 
     @Test
     fun `直达下标命中时直接采用`() {
@@ -39,6 +46,25 @@ class ReaderSearchMatcherTest {
         )
     }
 
+    @Test
+    fun `扣除标题前缀后的直达下标优先于同词出现次数`() {
+        // 检索层的章节文本是“标题\\n正文”,Canvas 只以正文建立坐标。
+        // 若不先扣标题长度,原下标可能碰巧指向正文中的另一个同词。
+        val content = "灯塔在前,灯塔在后。"
+        assertEquals(
+            ReaderSearchMatch(0, 2),
+            ReaderSearchMatcher.find(
+                content,
+                "灯塔",
+                request(
+                    directIndex = 3,
+                    occurrence = 1,
+                    leadingCharactersExcludedFromContent = 3,
+                ),
+            ),
+        )
+    }
+
     @Test
     fun `空查询或正文无结果返回 null`() {
         assertNull(ReaderSearchMatcher.find("海边有座灯塔。", "", request(directIndex = 0)))