Răsfoiți Sursa

fix: 阅读器相关

HapeLee 2 luni în urmă
părinte
comite
1587ceeee5

Fișier diff suprimat deoarece este prea mare
+ 489 - 363
app/src/main/java/io/legado/app/feature/reader/ReaderCanvasSurface.kt


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

@@ -1,12 +1,12 @@
 package io.legado.app.feature.reader.core.layout
 
 import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderEmphasisUnderline
 import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.model.ReaderPageDecoration
 import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderRect
 import io.legado.app.feature.reader.core.model.ReaderTextStyle
-import io.legado.app.feature.reader.core.model.ReaderEmphasisUnderline
 import kotlin.math.max
 
 enum class ReaderTextAlignment { START, CENTER, END, JUSTIFY }
@@ -41,6 +41,8 @@ data class ReaderPaginationConfig(
     val titleBottomSpacingPx: Float = 0f,
     val titleParagraphSpacingPx: Float? = null,
     val titleSegmentSpacingPx: Float = 0f,
+    /** 卷/空正文章整页只有标题时,标题块在内容区内垂直居中(对照原版空章标题页)。 */
+    val titlePageCenterVertical: Boolean = false,
     val columnCount: Int = 1,
     val lineSpacingMultiplier: Float = 1f,
     val continuousScroll: Boolean = false,
@@ -330,6 +332,8 @@ object ReaderPaginator {
                         link = paragraph.link,
                         chapterPosition = paragraph.chapterPosition + characterOffset,
                         paragraphIndex = paragraphIndex,
+                        // 整段共用一个 style:同行内第二字起若带背景图,即与前一字同 run
+                        continuesBackgroundRun = paragraph.style.backgroundImage != null && clusterIndex > from,
                     )
                     pageText.append(value)
                     characterOffset += value.length
@@ -537,6 +541,8 @@ object ReaderPaginator {
                 }
                 lineItems.forEachIndexed { itemIndex, item ->
                     x += backgroundInsetBefore(itemIndex)
+                    val itemBackground = (item as? ReaderMeasuredInlineItem.Text)
+                        ?.style?.backgroundImage
                     when (item) {
                         is ReaderMeasuredInlineItem.Text -> {
                             val expandedWordSpace = if (item.value == " ") wordSpaceExtra else 0f
@@ -553,6 +559,11 @@ object ReaderPaginator {
                                 markingId = item.markingId,
                                 chapterPosition = item.chapterPosition,
                                 paragraphIndex = paragraphIndex,
+                                // 富文本逐项样式:与前一项同背景图才视作同一 run 的延续
+                                continuesBackgroundRun = itemBackground != null &&
+                                        itemIndex > 0 &&
+                                        (lineItems[itemIndex - 1] as? ReaderMeasuredInlineItem.Text)
+                                            ?.style?.backgroundImage == itemBackground,
                                 backgroundFrameTopPx = item.style.backgroundImage?.takeIf { it.fit == 3 }?.let { image ->
                                     val halfGap = ((paragraph.lineSpacingMultiplier - 1f).coerceAtLeast(0f) * actualLineHeight) / 2f
                                     val scale = (halfGap / maxOf(image.contentInsetTopPx, image.contentInsetBottomPx)
@@ -652,6 +663,22 @@ object ReaderPaginator {
             }
         }
         finishPage()
+        if (config.titlePageCenterVertical && pages.size == 1) {
+            // 卷页只有标题块:按字形实际占位整体下移到内容区垂直中点。布局期平移
+            // 保证命中测试、选区与进度映射共用同一几何。
+            val pageElements = pages[0]
+            val top = pageElements.minOf { it.bounds.top }
+            val bottom = pageElements.maxOf { it.bounds.bottom }
+            val available = config.contentBottomPx - config.paddingTopPx
+            if (bottom - top < available) {
+                val delta = config.paddingTopPx + (available - (bottom - top)) / 2f - top
+                if (delta != 0f) {
+                    for (elementIndex in pageElements.indices) {
+                        pageElements[elementIndex] = shiftElement(pageElements[elementIndex], delta)
+                    }
+                }
+            }
+        }
         return pages.mapIndexed { pageIndex, pageElements ->
             ReaderPage(
                 id = ReaderPageId(config.chapterIndex, pageIndex),

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

@@ -93,6 +93,8 @@ sealed interface ReaderElement {
         val paragraphIndex: Int = -1,
         val backgroundFrameTopPx: Float = 0f,
         val backgroundFrameBottomPx: Float = 0f,
+        /** 同一行内紧随同背景图元素之后(对照旧 View TextLine 的行内连续绘制)。 */
+        val continuesBackgroundRun: Boolean = false,
     ) : ReaderElement {
         /** HTML links keep the legacy reader's accent priority, including during read-aloud. */
         fun resolvedColorArgb(accentColorArgb: Int): Int =

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

@@ -10,14 +10,33 @@ data class ReaderTextBackgroundRun(
 
 fun ReaderPage.textBackgroundRuns(): List<ReaderTextBackgroundRun> {
     val runs = mutableListOf<ReaderTextBackgroundRun>()
-    elements.filterIsInstance<ReaderElement.Text>().forEach { text ->
-        val image = text.style.backgroundImage ?: return@forEach
+    // 行级合并对照旧 View TextLine.drawStyledBackgrounds:同一行内相邻且同背景图
+    // 的字合并为一段,一次性绘制。元素相邻是硬条件(未被匹配的字会打断 run);
+    // 字间距/两端对齐产生的间隙由分页期标记 continuesBackgroundRun 放行,既避免
+    // 逐字渲染背景,也不会把跨栏/跨行或隔着未匹配文字的同图段错误拼接。
+    var previousElement: ReaderElement? = null
+    elements.forEach { element ->
+        val text = element as? ReaderElement.Text
+        if (text == null) {
+            previousElement = element
+            return@forEach
+        }
+        val image = text.style.backgroundImage
+        if (image == null) {
+            previousElement = text
+            return@forEach
+        }
         val previous = runs.lastOrNull()
-        if (
-            previous != null && previous.image == image &&
+        val previousIsSameImage = (previousElement as? ReaderElement.Text)
+            ?.style?.backgroundImage == image
+        val sameRow = previous != null &&
             abs(previous.contentBounds.top - text.bounds.top) < 0.5f &&
-            abs(previous.contentBounds.bottom - text.bounds.bottom) < 0.5f &&
+                abs(previous.contentBounds.bottom - text.bounds.bottom) < 0.5f
+        val contiguous = previous != null &&
             abs(previous.contentBounds.right - text.bounds.left) < 1f
+        if (
+            previous != null && previousIsSameImage && sameRow &&
+            (contiguous || text.continuesBackgroundRun)
         ) {
             runs[runs.lastIndex] = previous.copy(
                 bounds = previous.bounds.copy(
@@ -37,6 +56,7 @@ fun ReaderPage.textBackgroundRuns(): List<ReaderTextBackgroundRun> {
                 image = image,
             )
         }
+        previousElement = text
     }
     return runs.map { run ->
         if (run.image.fit != 3) run else run.copy(

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

@@ -90,7 +90,7 @@ object ReaderPageTransitionPolicy {
         currentOffsetPx: Float,
         targetOffsetPx: Float,
         pageExtentPx: Float,
-        baseDurationMillis: Int = 300,
+        baseDurationMillis: Int = 360,
     ): Int {
         if (pageExtentPx <= 0f || baseDurationMillis <= 0 || currentOffsetPx == targetOffsetPx) return 0
         // FadePageDelegate animates its normalized alpha for the full configured duration.

+ 3 - 2
app/src/main/java/io/legado/app/feature/reader/legacy/LegacyReaderChapterPaginator.kt

@@ -15,10 +15,10 @@ import io.legado.app.feature.reader.core.layout.ReaderTextAlignment
 import io.legado.app.feature.reader.core.layout.ReaderTextShaperFactory
 import io.legado.app.feature.reader.core.model.ReaderPage
 import io.legado.app.feature.reader.core.source.ReaderChapterSource
-import io.legado.app.feature.reader.platform.AndroidReaderTextShaper
 import io.legado.app.feature.reader.platform.AndroidReaderHtmlSourceResolver
-import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
+import io.legado.app.feature.reader.platform.AndroidReaderTextShaper
 import io.legado.app.feature.reader.platform.ReaderAndroidPaginationStyle
+import io.legado.app.feature.reader.platform.ReaderAndroidPaintFactory
 import io.legado.app.help.book.BookContent
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.ImageProvider
@@ -219,6 +219,7 @@ object LegacyReaderChapterPaginator {
                 paragraphSpacingPx = paginationStyle.bodyTextHeightPx * paginationStyle.paragraphSpacing / 10f,
                 titleTopSpacingPx = paginationStyle.titleTopSpacingPx,
                 titleBottomSpacingPx = paginationStyle.titleBottomSpacingPx,
+                titlePageCenterVertical = chapter.isVolume || content.textList.isEmpty(),
                 titleParagraphSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.paragraphSpacing / 10f,
                 titleSegmentSpacingPx = paginationStyle.titleTextHeightPx * paginationStyle.titleLineSpacingSub,
                 letterSpacingPx = bodyPaint.letterSpacing * bodyPaint.textSize,

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

@@ -1,9 +1,9 @@
 package io.legado.app.feature.reader.core.layout
 
 import io.legado.app.feature.reader.core.model.ReaderElement
-import io.legado.app.feature.reader.core.model.ReaderTextStyle
-import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
 import io.legado.app.feature.reader.core.model.ReaderEmphasisUnderline
+import io.legado.app.feature.reader.core.model.ReaderTextBackgroundImage
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
 import io.legado.app.feature.reader.core.model.textBackgroundRuns
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
@@ -516,4 +516,21 @@ class ReaderPaginatorTest {
         assertEquals(glyph.bounds.top - 10f / 3f, run.bounds.top, 0.001f)
         assertEquals(glyph.bounds.bottom + 5f, run.bounds.bottom, 0.001f)
     }
+
+    @Test
+    fun letterSpacedBackgroundRowStaysOneRunInsteadOfPerGlyph() {
+        val bgStyle = style.copy(backgroundImage = ReaderTextBackgroundImage("bg.png", 1, 1f))
+        val paragraph = ReaderMeasuredParagraph(
+            "甲乙丙", listOf("甲", "乙", "丙"), List(3) { 10f }, bgStyle, 0,
+            letterSpacingPx = 2f,
+        )
+        val page = ReaderPaginator.paginate(listOf(paragraph), config).single()
+
+        val glyphs = page.elements.filterIsInstance<ReaderElement.Text>()
+        // 字间距在字形间留下 2f 间隙:旧实现按字拆 run,现在整行合并为一段
+        assertEquals(listOf(0f, 12f, 24f), glyphs.map { it.bounds.left })
+        val runs = page.textBackgroundRuns()
+        assertEquals(1, runs.size)
+        assertEquals(34f, runs.single().contentBounds.right, 0f)
+    }
 }

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

@@ -0,0 +1,60 @@
+package io.legado.app.feature.reader.core.layout
+
+import io.legado.app.feature.reader.core.model.ReaderElement
+import io.legado.app.feature.reader.core.model.ReaderTextStyle
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/** 卷/空正文章整页只有标题时的垂直居中行为。 */
+class ReaderVolumeTitlePageTest {
+    private val style = ReaderTextStyle(0, 10f)
+
+    private fun titleParagraph(value: String, position: Int = 0) = ReaderMeasuredParagraph(
+        value, value.map(Char::toString), List(value.length) { 10f }, style, position,
+        isTitle = true,
+    )
+
+    private fun config(centerVertical: Boolean) = ReaderPaginationConfig(
+        chapterIndex = 0, chapterTitle = "第一卷", viewportWidthPx = 100, viewportHeightPx = 200,
+        paddingLeftPx = 5f, paddingRightPx = 5f, paddingTopPx = 10f, paddingBottomPx = 20f,
+        lineHeightPx = 20f, baselineOffsetPx = 15f,
+        titleTopSpacingPx = 5f,
+        titlePageCenterVertical = centerVertical,
+    )
+
+    @Test
+    fun titleOnlyPageIsVerticallyCenteredInContentArea() {
+        val page = ReaderPaginator.paginate(
+            listOf(titleParagraph("第一卷 风起")), config(centerVertical = true),
+        ).single()
+        val glyphs = page.elements.filterIsInstance<ReaderElement.Text>()
+        val top = glyphs.minOf { it.bounds.top }
+        val bottom = glyphs.maxOf { it.bounds.bottom }
+        // 内容区 [10, 180],占位 20f:上留白应等于下留白
+        assertEquals(85f, top, 0.01f)
+        assertEquals(105f, bottom, 0.01f)
+        assertEquals(170f - (top - 10f), bottom - 10f, 0.01f)
+    }
+
+    @Test
+    fun defaultLayoutKeepsTitleTopAnchored() {
+        val page = ReaderPaginator.paginate(
+            listOf(titleParagraph("第一卷 风起")), config(centerVertical = false),
+        ).single()
+        val top = page.elements.minOf { it.bounds.top }
+        assertEquals(15f, top, 0f)
+    }
+
+    @Test
+    fun baselineShiftsTogetherWithBounds() {
+        val page = ReaderPaginator.paginate(
+            listOf(titleParagraph("第一卷")), config(centerVertical = true),
+        ).single()
+        val glyphs = page.elements.filterIsInstance<ReaderElement.Text>()
+        val top = glyphs.minOf { it.bounds.top }
+        // 居中后 titleTopSpacing 被抵消:基线 = 内容区顶 + 半留白 + baselineOffset
+        glyphs.forEach { glyph ->
+            assertEquals(top + 15f, glyph.baselinePx, 0.01f)
+        }
+    }
+}

+ 51 - 10
app/src/test/java/io/legado/app/feature/reader/core/model/ReaderTextBackgroundRunTest.kt

@@ -7,6 +7,7 @@ class ReaderTextBackgroundRunTest {
 
     private val image = ReaderTextBackgroundImage("background.png", fit = 3, scale = 1f)
     private val style = ReaderTextStyle(0xFF000000.toInt(), 20f, backgroundImage = image)
+    private val plainStyle = ReaderTextStyle(0xFF000000.toInt(), 20f)
 
     @Test
     fun `merges adjacent text with the same background on one line`() {
@@ -18,6 +19,39 @@ class ReaderTextBackgroundRunTest {
         assertEquals(listOf(ReaderRect(0f, 0f, 25f, 20f)), page.textBackgroundRuns().map { it.bounds })
     }
 
+    @Test
+    fun `letter spacing gap continues the run when pagination marks it`() {
+        val page = page(
+            text(0f, 0f, 10f, 20f, style),
+            text(12f, 0f, 22f, 20f, style, continues = true),
+        )
+
+        assertEquals(
+            listOf(ReaderRect(0f, 0f, 22f, 20f)),
+            page.textBackgroundRuns().map { it.bounds })
+    }
+
+    @Test
+    fun `unmatched glyph between same image ranges breaks the run`() {
+        val page = page(
+            text(0f, 0f, 10f, 20f, style),
+            text(10f, 0f, 20f, 20f, plainStyle),
+            text(20f, 0f, 30f, 20f, style, continues = true),
+        )
+
+        assertEquals(2, page.textBackgroundRuns().size)
+    }
+
+    @Test
+    fun `flagged continuation does not cross rows`() {
+        val page = page(
+            text(0f, 0f, 10f, 20f, style),
+            text(12f, 20f, 22f, 40f, style, continues = true),
+        )
+
+        assertEquals(2, page.textBackgroundRuns().size)
+    }
+
     @Test
     fun `does not merge across lines gaps or different images`() {
         val other = style.copy(backgroundImage = image.copy(source = "other.png"))
@@ -58,16 +92,23 @@ class ReaderTextBackgroundRunTest {
         assertEquals(8f, resolved.contentInsetBottomPx, 0f)
     }
 
-    private fun text(left: Float, top: Float, right: Float, bottom: Float, textStyle: ReaderTextStyle) =
-        ReaderElement.Text(
-            bounds = ReaderRect(left, top, right, bottom),
-            baselinePx = bottom - 4f,
-            value = "字",
-            style = textStyle,
-            selected = false,
-            emphasized = false,
-            chapterPosition = 0,
-        )
+    private fun text(
+        left: Float,
+        top: Float,
+        right: Float,
+        bottom: Float,
+        textStyle: ReaderTextStyle,
+        continues: Boolean = false,
+    ) = ReaderElement.Text(
+        bounds = ReaderRect(left, top, right, bottom),
+        baselinePx = bottom - 4f,
+        value = "字",
+        style = textStyle,
+        selected = false,
+        emphasized = false,
+        chapterPosition = 0,
+        continuesBackgroundRun = continues,
+    )
 
     private fun page(vararg elements: ReaderElement) = ReaderPage(
         id = ReaderPageId(0, 0),

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff